diff --git a/.github/workflows/notebook-link-render-check.yml b/.github/workflows/notebook-link-render-check.yml index 6ab3ca465b..11b3dc3886 100644 --- a/.github/workflows/notebook-link-render-check.yml +++ b/.github/workflows/notebook-link-render-check.yml @@ -72,30 +72,52 @@ jobs: mapfile -t PR_READMES < <(git diff --name-only --diff-filter=d "$MERGE_BASE" HEAD -- 'MyIA.AI.Notebooks/**/README.md' | sort -u) n_readmes=${#PR_READMES[@]} pr_brut=0; main_brut=0 + pr_manque=0; main_manque=0 + scan_errors=0 for readme in "${PR_READMES[@]}"; do [ -z "$readme" ] && continue # Le parent du README dans le repo (le checker a besoin du VRAI chemin # pour resoudre les liens .ipynb relatifs -- le dump dans /tmp casse # la resolution par defaut, d'ou --link-root). readme_dir="$(dirname "$readme")" + # Un echec du checker ne compte PAS comme 0 lien : il incremente + # scan_errors, sinon le delta peut sous-estimer un +BRUT reel + # (#14435 rem. 1 -- zero fabrique par organe muet). # Version PR (HEAD). if git show "HEAD:$readme" > /tmp/_pr_readme.md 2>/dev/null; then - p=$(cd /tmp && python "$GITHUB_WORKSPACE/scripts/notebook_tools/check_notebook_link_render.py" --tracked-only --json /tmp/_pr_readme.md --link-root "$GITHUB_WORKSPACE/$readme_dir" 2>/dev/null \ - | python -c "import json,sys; print(json.load(sys.stdin)['summary']['totals']['BRUT'])" 2>/dev/null || echo 0) - pr_brut=$((pr_brut + p)) + if line=$(cd /tmp && python "$GITHUB_WORKSPACE/scripts/notebook_tools/check_notebook_link_render.py" --tracked-only --json /tmp/_pr_readme.md --link-root "$GITHUB_WORKSPACE/$readme_dir" 2>/dev/null \ + | python -c "import json,sys; t=json.load(sys.stdin)['summary']['totals']; print(t['BRUT'], t['MANQUE'])" 2>/dev/null) && [ -n "$line" ]; then + read -r p_brut p_manque <<< "$line" + pr_brut=$((pr_brut + p_brut)) + pr_manque=$((pr_manque + p_manque)) + else + scan_errors=$((scan_errors + 1)) + fi fi # Version main (merge-base). if git show "$MERGE_BASE:$readme" > /tmp/_main_readme.md 2>/dev/null; then - m=$(cd /tmp && python "$GITHUB_WORKSPACE/scripts/notebook_tools/check_notebook_link_render.py" --tracked-only --json /tmp/_main_readme.md --link-root "$GITHUB_WORKSPACE/$readme_dir" 2>/dev/null \ - | python -c "import json,sys; print(json.load(sys.stdin)['summary']['totals']['BRUT'])" 2>/dev/null || echo 0) - main_brut=$((main_brut + m)) + if line=$(cd /tmp && python "$GITHUB_WORKSPACE/scripts/notebook_tools/check_notebook_link_render.py" --tracked-only --json /tmp/_main_readme.md --link-root "$GITHUB_WORKSPACE/$readme_dir" 2>/dev/null \ + | python -c "import json,sys; t=json.load(sys.stdin)['summary']['totals']; print(t['BRUT'], t['MANQUE'])" 2>/dev/null) && [ -n "$line" ]; then + read -r m_brut m_manque <<< "$line" + main_brut=$((main_brut + m_brut)) + main_manque=$((main_manque + m_manque)) + else + scan_errors=$((scan_errors + 1)) + fi fi done delta=$((pr_brut - main_brut)) if [ "$delta" -gt 0 ]; then delta_sign="+"; else delta_sign=""; fi - echo "PR READMEs scanned: $n_readmes | PR BRUT: $pr_brut | main BRUT: $main_brut | delta: ${delta_sign}${delta}" + manque_delta=$((pr_manque - main_manque)) + if [ "$manque_delta" -gt 0 ]; then manque_sign="+"; else manque_sign=""; fi + echo "PR READMEs scanned: $n_readmes | PR BRUT: $pr_brut | main BRUT: $main_brut | delta: ${delta_sign}${delta} | MANQUE: ${pr_manque} vs ${main_manque} (${manque_sign}${manque_delta}) | scan-errors: $scan_errors" echo "delta_brut=$delta" >> "$GITHUB_OUTPUT" echo "delta_sign=$delta_sign" >> "$GITHUB_OUTPUT" + # Le delta trace aussi MANQUE : un README qui perd un notebook (lien + # dangling) ne reste plus a delta BRUT 0 (#14435 rem. 2). + echo "delta_manque=$manque_delta" >> "$GITHUB_OUTPUT" + echo "pr_manque=$pr_manque" >> "$GITHUB_OUTPUT" + echo "scan_errors=$scan_errors" >> "$GITHUB_OUTPUT" echo "pr_readmes=$n_readmes" >> "$GITHUB_OUTPUT" set -e @@ -104,11 +126,15 @@ jobs: with: script: | // Label delta signe (jamais bloquant) sur la PR -- ce que la PR a - // reellement modifie (N readmes scannes, +/- BRUT). + // reellement modifie (N readmes scannes, +/- BRUT, delta MANQUE, + // et echecs de scan nommes : un organe muet rend la meme valeur + // qu'un organe qui a mesure et n'a rien trouve, #14435 rem. 1-2). const delta = '${{ steps.scan.outputs.delta_brut }}'; const deltaSign = '${{ steps.scan.outputs.delta_sign }}'; const prReadmes = '${{ steps.scan.outputs.pr_readmes }}'; - const deltaLabel = `notebook-link-render-delta: ${deltaSign}${delta} (${prReadmes} readmes)`; + const manque = '${{ steps.scan.outputs.delta_manque }}'; + const scanErrors = '${{ steps.scan.outputs.scan_errors }}'; + const deltaLabel = `notebook-link-render-delta: ${deltaSign}${delta} (${prReadmes} readmes, manque ${manque}, scan-err ${scanErrors})`; // Cleanup des anciens labels notebook-link-render-* de runs precedents. let existing = []; diff --git a/scripts/notebook_tools/scan_arxiv_citations.py b/scripts/notebook_tools/scan_arxiv_citations.py index ab31f57f28..ce6e8c33c7 100644 --- a/scripts/notebook_tools/scan_arxiv_citations.py +++ b/scripts/notebook_tools/scan_arxiv_citations.py @@ -22,8 +22,11 @@ ARXIV_RE = re.compile(r"\barXiv:\s*(\d{4}\.\d{4,5})\b") # Legacy : arXiv:cs.LG/NNNNNNN ou arXiv:math.AG/NNNNNNN ou arXiv:hep-th/NNNNNNN +# Le préfixe d'archive FAIT partie de l'identifiant legacy : l'API arXiv +# rejette (400) un identifiant ancien réduit à ses 7 chiffres. La capture +# inclut donc le préfixe quand il est présent (#14435, rem. 3). ARXIV_RE_LEGACY = re.compile( - r"\barXiv:\s*(?:[a-z\-]+(?:\.[A-Z]{2})?/)?(\d{7})\b" + r"\barXiv:\s*((?:[a-z\-]+(?:\.[A-Z]{2})?/)?\d{7})\b" ) @@ -56,7 +59,8 @@ def scan_notebook(nb_path: Path): for m in ARXIV_RE_LEGACY.finditer(src): arxiv_id = m.group(1) # éviter les faux positifs sur les modernes (7 chiffres != 9) - if len(arxiv_id) == 7: + digits = arxiv_id.rsplit("/", 1)[-1] + if len(digits) == 7: found.append((idx, arxiv_id)) return found diff --git a/scripts/notebook_tools/scan_pr_arxiv_diff.py b/scripts/notebook_tools/scan_pr_arxiv_diff.py index 9cc378497c..6a3ab9c305 100644 --- a/scripts/notebook_tools/scan_pr_arxiv_diff.py +++ b/scripts/notebook_tools/scan_pr_arxiv_diff.py @@ -15,7 +15,9 @@ from pathlib import Path ARXIV_RE = re.compile(r"\barXiv:\s*(\d{4}\.\d{4,5})\b") -ARXIV_RE_LEGACY = re.compile(r"\barXiv:\s*(?:[a-z\-]+(?:\.[A-Z]{2})?/)?(\d{7})\b") +# Le préfixe d'archive fait partie de l'ID legacy (bare 7 chiffres = 400 API, +# #14435 rem. 3) — la capture l'inclut quand il est présent. +ARXIV_RE_LEGACY = re.compile(r"\barXiv:\s*((?:[a-z\-]+(?:\.[A-Z]{2})?/)?\d{7})\b") def run(cmd, cwd=None): @@ -76,7 +78,7 @@ def extract_arxiv_from_text(text): ids.add(m.group(1)) for m in ARXIV_RE_LEGACY.finditer(text): aid = m.group(1) - if len(aid) == 7: + if len(aid.rsplit("/", 1)[-1]) == 7: ids.add(aid) return ids diff --git a/scripts/notebook_tools/tests/test_scan_arxiv_prefix.py b/scripts/notebook_tools/tests/test_scan_arxiv_prefix.py new file mode 100644 index 0000000000..8b5f87ec59 --- /dev/null +++ b/scripts/notebook_tools/tests/test_scan_arxiv_prefix.py @@ -0,0 +1,64 @@ +"""Tests du fix #14435 (rem. 3) : l'ID legacy arXiv garde son préfixe d'archive. + +Un identifiant ancien réduit à ses 7 chiffres est rejeté par l'API arXiv +(400) — le préfixe (`cs/`, `quant-ph/`, `cat/`, `math.AG/`) FAIT partie de +l'identifiant. Ces tests échouent si un scanner se remet à le capturer nu. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from scan_arxiv_citations import scan_notebook # noqa: E402 +from scan_pr_arxiv_diff import extract_arxiv_from_text # noqa: E402 + + +def _nb(tmp_path, md_text): + """Écrire un notebook minimal d'une cellule markdown, retourner son chemin.""" + import nbformat + nb = nbformat.v4.new_notebook() + nb.cells = [nbformat.v4.new_markdown_cell(md_text)] + p = tmp_path / "nb.ipynb" + nbformat.write(nb, p) + return p + + +class TestPrefixedLegacyIds: + def test_prefixed_id_keeps_its_prefix_scan_citations(self, tmp_path): + p = _nb(tmp_path, "Voir arXiv:cs/0011047 pour le détail.") + assert scan_notebook(p) == [(0, "cs/0011047")] + + def test_prefixed_subject_id_keeps_prefix_scan_citations(self, tmp_path): + p = _nb(tmp_path, "arXiv:math.AG/0309136 et arXiv:quant-ph/0604079.") + assert {aid for _, aid in scan_notebook(p)} == {"math.AG/0309136", "quant-ph/0604079"} + + def test_cat_prefix_is_a_valid_archive_prefix(self, tmp_path): + # `cat` matche [a-z\-]+ -- le cas nommé par #14435 rem. 3. + p = _nb(tmp_path, "arXiv:cat/0703165.") + assert scan_notebook(p) == [(0, "cat/0703165")] + + def test_prefixed_id_keeps_its_prefix_pr_diff(self): + assert extract_arxiv_from_text("arXiv:cs/0011047") == {"cs/0011047"} + + def test_bare_seven_digit_id_unchanged(self): + # Un legacy SANS préfixe reste nu (l'auteur l'a écrit ainsi). + assert extract_arxiv_from_text("arXiv:0703123") == {"0703123"} + assert extract_arxiv_from_text("arXiv:0703123") == {"0703123"} + + def test_modern_id_unchanged(self): + assert extract_arxiv_from_text("arXiv:2301.12345") == {"2301.12345"} + assert extract_arxiv_from_text("arXiv:2301.12345, arXiv:cs/0011047") == { + "2301.12345", "cs/0011047" + } + + def test_eight_digit_sequence_is_not_legacy(self): + # 8 chiffres : ni moderne (pas de point) ni legacy (7 chiffres) -> rien. + assert extract_arxiv_from_text("arXiv:07031234") == set() + + +class TestGuardAgainstRegression: + def test_regex_capture_group_includes_prefix(self): + import scan_pr_arxiv_diff as spd + m = spd.ARXIV_RE_LEGACY.search("arXiv:quant-ph/0604079") + assert m is not None + assert m.group(1) == "quant-ph/0604079" # nu : "0604079" = le défaut