From 06830ea10e9f981879abd26c93958732eccf022b Mon Sep 17 00:00:00 2001 From: jsboige Date: Sat, 3 Oct 2026 07:23:48 +0200 Subject: [PATCH 1/5] fix(tests,#18896): guard_test_root -- bloc pytest sans xdist matche + erreur explicite si bloc absent CONCERNS coordinateur (c.86) sur #18896 : PYTEST_BLOCK_RE exigeait que le bloc pytest multi-lignes se termine par `-n ` (xdist). Un workflow valide contenant `python -m pytest scripts/notebook_tools/tests/ -q` (sans xdist) rendait une extraction VIDE, find_violations([]) rendait [], et main() sortait rc=0 'ok' sans rien verifier. Un test_uncollected.py a la racine de scripts/notebook_tools/ etait invisible pour le garde. Fix triple : 1. Le pattern accepte tout argument pytest en queue (le bloc se termine a la derniere ligne se terminant par \, pas forcement `-n `). Le test couvre le cas du CONCERNS + 2 cas voisins (avec -n, sans -n, sans aucune option). 2. Accepte `python -m pytest` en sus de `pytest` nu -- c'etait le cas exact du CONCERNS coordinateur. 3. Si le workflow contient `pytest \` (signe d'un appel multi-lignes intentionnel) mais que le bloc ne matche pas (auteur a oublie un chemin, ou bloc YAML bugge), on leve PytestBlockNotFound et main() rend rc=2 avec un message explicite. Le contrat est : 'soit on mesure, soit on refuse' ; le rc=0 silencieux n'est plus une option. Tests : scripts/ci/tests/test_guard_test_root.py (9 tests, 0.07s) : - test_parse_with_xdist_returns_all_paths : cas historique (avec -n) - test_parse_without_xdist_returns_paths : CONCERNS c.86 - test_parse_without_any_pytest_options : bloc pytest sans aucune option en queue (le pattern matche le minimum) - test_parse_pytest_no_multiline_block_raises : bloc pytest \ isole sans chemins -> leve PytestBlockNotFound - test_parse_inline_pytest_returns_empty : pytest inline (pas de convention multi-lignes) -> liste vide, distinct du cas precedent - test_parse_no_pytest_at_all_returns_empty : pas de pytest du tout - test_main_returns_2_on_pytest_block_not_found : rc=2 explicite - test_main_returns_0_on_clean_workflow : workflow propre, rc=0 - test_no_xdist_with_root_test_detects_violation : temoignage du CONCERNS -- avant le fix, paths=[] masquait test_uncollected.py Aucune regression sur scripts/ci/tests/test_check_hr_substitution.py (voisin, meme dossier). Le workflow reel .github/workflows/scripts-tests.yml rend 17 chemins, ok=True, 0 violation (avant et apres le fix). Refs: #18896 (CONCERNS coordinateur c.86 sur la tete f6f7bd9a6) Co-Authored-By: Claude Haiku 4.5 (1M context) --- scripts/ci/guard_test_root.py | 68 +++++- scripts/ci/tests/test_guard_test_root.py | 285 +++++++++++++++++++++++ 2 files changed, 345 insertions(+), 8 deletions(-) create mode 100644 scripts/ci/tests/test_guard_test_root.py diff --git a/scripts/ci/guard_test_root.py b/scripts/ci/guard_test_root.py index 88b8ec81bb..50f553a762 100644 --- a/scripts/ci/guard_test_root.py +++ b/scripts/ci/guard_test_root.py @@ -35,12 +35,35 @@ REPO_ROOT = Path(__file__).resolve().parents[2] DEFAULT_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "scripts-tests.yml" -# On matche le bloc pytest multi-lignes : pytest \ puis les chemins -# (termines par \) jusqu'a `-n ` qui signale la fin des args pytest. + +class PytestBlockNotFound(Exception): + """Le workflow contient `pytest` mais le bloc multi-lignes ne matche + pas. C'est un defaut de lecture -- le garde ne peut pas verifier + quoi que ce soit et DOIT le signaler plutot que rendre `ok=True` a + tort. Cf. CONCERNS coordinateur (c.86) sur #18896.""" + +# On matche le bloc pytest multi-lignes : pytest (ou `python -m pytest`) +# suivi de `\` puis les chemins (termines par \). La fin du bloc est +# signalee par une ligne qui ne se termine PAS par \ (apres, pytest +# prend d'autres options : -n, -q, --dist, --tb, ...). Cf. #18896 +# (CONCERNS c.86) : la version precedente exigeait `-n` en fin de +# bloc, ce qui rendait l extraction VIDE pour les workflows pytest +# valides sans xdist (python -m pytest scripts/notebook_tools/tests/ -q) +# -- le garde rendait alors ok=True sans rien verifier. +# +# Strategie : capturer toutes les lignes consecutives se terminant par +# `\`, en arretant des qu'une ligne ne se termine PAS par `\` (ou fin +# de fichier). Le `+?` non-greedy consomme le minimum de lignes +# consecutives, et le lookahead garantit qu'on s'arrete a la premiere +# ligne non-`-terminee-par-\`. +PYTEST_INVOCATION_RE = re.compile( + r"(?:python\s+(?:-m\s+)?|pytest(?:\s+-m\s+\S+)?\s+)?pytest\s*\\[ \t]*\n", + re.MULTILINE, +) PYTEST_BLOCK_RE = re.compile( - r"pytest\s*\\\s*\n" - r"((?:[^\n]*\\\s*\n)+?)" - r"\s*-n\s", + PYTEST_INVOCATION_RE.pattern + + r"((?:.*\\[ \t]*\n)+?)" + + r"(?:[^\\\n]*\n|\Z)", re.MULTILINE, ) @@ -52,11 +75,29 @@ def parse_collected_paths(workflow: Path) -> list[str]: bloc, on nettoie les continuations de ligne, on splitte sur les espaces, on filtre tout ce qui ressemble a un flag (-x, --dist, ...) ou un nombre. + + Si le workflow ne contient pas du tout `pytest`, on rend une liste + vide (le workflow n'est pas dans le scope de cette garde). Si le + workflow contient `pytest` mais que le bloc multi-lignes ne matche + pas (pattern en-dessous du reel, ou format inhabituel), on leve + ``PytestBlockNotFound`` : le garde DOIT refuser plutot que rendre + ``ok=True`` sans rien verifier (#18896 c.86). """ text = workflow.read_text(encoding="utf-8") + # Le mot "pytest" peut apparaitre dans un nom de fichier (no-pytest.yml), + # un commentaire, un nom de job, etc. On ne leve l'exception que si le + # workflow contient un APPEL pytest multi-lignes (pytest \ en fin de + # ligne, apres indentation YAML ou apres `python -m`). C'est le signe + # qu'un bloc DEVRAIT etre reconnu. + if not PYTEST_INVOCATION_RE.search(text): + return [] match = PYTEST_BLOCK_RE.search(text) if not match: - return [] + raise PytestBlockNotFound( + f"{workflow} contient `pytest \\` mais PYTEST_BLOCK_RE ne matche " + f"pas le bloc multi-lignes ; le garde ne peut pas verifier. " + f"Cf. CONCERNS coordinateur (c.86) sur #18896." + ) block = match.group(1) joined = re.sub(r"\s*\\\s*\n\s*", " ", block) tokens = joined.split() @@ -172,6 +213,13 @@ def main(argv: list[str] | None = None) -> int: try: collected = parse_collected_paths(args.workflow) + except PytestBlockNotFound as e: + # #18896 (c.86) : si le workflow contient `pytest` mais que le + # pattern multi-lignes ne matche pas, c'est un defaut de lecture + # -- le garde rendait `ok=True` sans rien verifier. On remonte + # un statut explicite (rc=2) et un message qui nomme la cause. + print(f"FAIL: {e}", file=sys.stderr) + return 2 except Exception as e: print(f"FAIL: impossible de parser {args.workflow}: {e}", file=sys.stderr) return 2 @@ -180,7 +228,7 @@ def main(argv: list[str] | None = None) -> int: if args.json: out = { - "workflow": str(args.workflow.relative_to(REPO_ROOT)), + "workflow": str(args.workflow.resolve().relative_to(REPO_ROOT)), "collected_paths": collected, "violations": [str(v.relative_to(REPO_ROOT)) for v in violations], "ok": not violations, @@ -197,7 +245,11 @@ def main(argv: list[str] | None = None) -> int: print(f"\nDeplacer dans le sous-dossier collecte adapte.") return 1 print(f"OK: tous les test_*.py sont dans les chemins collectes.") - print(f"Chemins collectes (lus depuis {args.workflow.relative_to(REPO_ROOT)}) :") + try: + wf_display = args.workflow.resolve().relative_to(REPO_ROOT) + except ValueError: + wf_display = args.workflow + print(f"Chemins collectes (lus depuis {wf_display}) :") for c in collected: print(f" - {c}") diff --git a/scripts/ci/tests/test_guard_test_root.py b/scripts/ci/tests/test_guard_test_root.py new file mode 100644 index 0000000000..dcd6104e71 --- /dev/null +++ b/scripts/ci/tests/test_guard_test_root.py @@ -0,0 +1,285 @@ +"""Tests du parseur `guard_test_root.py` (CONCERNS coordinateur c.86, #18896). + +Trois cas exiges par l'arbitrage du 03/10 : + + 1. bloc pytest valide AVEC `-n` (xdist) : le pattern matche, la liste + des chemins collectes est non-vide. + 2. bloc pytest valide SANS `-n` (juste `-q`) : le pattern matche + (avant le fix c.86, il rendait une liste vide et le garde disait + `ok=True` sans rien verifier -- un faux positif silencieux). + 3. workflow mentionnant `pytest` sans bloc multi-lignes reconnu : + `parse_collected_paths` leve `PytestBlockNotFound` ; le `main` + rend `rc=2` et un message explicite. + +Ces tests sont network-free : le module est importe en local, les +fichiers YAML de test sont crees dans un `tmp_path` pytest. +""" +import importlib.util +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_GUARD = _REPO_ROOT / "scripts" / "ci" / "guard_test_root.py" +_SPEC = importlib.util.spec_from_file_location("guard_test_root", _GUARD) +assert _SPEC is not None and _SPEC.loader is not None +guard = importlib.util.module_from_spec(_SPEC) +sys.modules["guard_test_root"] = guard +_SPEC.loader.exec_module(guard) + + +# --------------------------------------------------------------------------- +# 1. Bloc pytest valide AVEC `-n` (le cas historique) +# --------------------------------------------------------------------------- + +def test_parse_with_xdist_returns_all_paths(tmp_path): + """Bloc pytest avec `-n 4 --dist loadscope` : 17 chemins collectes + sur le workflow reel. Le test isole un sous-ensemble a 4 chemins + pour eviter une re-execution des autres jobs.""" + wf = tmp_path / "with_xdist.yml" + wf.write_text( + "name: with-xdist\n" + "on: [push]\n" + "jobs:\n" + " t:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - run: |\n" + " pytest \\\n" + " scripts/tests \\\n" + " scripts/notebook_tools/tests \\\n" + " scripts/lean/tests \\\n" + " MyIA.AI.Notebooks/GameTheory/tests \\\n" + " -n 4 --dist loadscope --tb=short -q\n" + ) + paths = guard.parse_collected_paths(wf) + assert paths == [ + "scripts/tests", + "scripts/notebook_tools/tests", + "scripts/lean/tests", + "MyIA.AI.Notebooks/GameTheory/tests", + ] + + +# --------------------------------------------------------------------------- +# 2. Bloc pytest valide SANS `-n` (le cas du CONCERNS c.86) +# --------------------------------------------------------------------------- + +def test_parse_without_xdist_returns_paths(tmp_path): + """Bloc pytest avec juste `-q` en queue : avant le fix c.86, le + pattern exigeait `-n\\s` en queue, donc cette fixture rendait une + liste vide et le garde disait `ok=True` sans rien verifier. Le + fix accepte tout argument pytest en queue (le bloc se termine a + la derniere ligne se terminant par `\\`). + """ + wf = tmp_path / "no_xdist.yml" + wf.write_text( + "name: no-xdist\n" + "on: [push]\n" + "jobs:\n" + " t:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - run: |\n" + " python -m pytest \\\n" + " scripts/notebook_tools/tests/ \\\n" + " -q\n" + ) + paths = guard.parse_collected_paths(wf) + assert paths == ["scripts/notebook_tools/tests/"] + + +def test_parse_without_any_pytest_options(tmp_path): + """Bloc pytest sans aucune option en queue (juste les chemins). + Le pattern matche car le bloc se termine a la derniere ligne `\\`.""" + wf = tmp_path / "bare.yml" + wf.write_text( + "name: bare\n" + "on: [push]\n" + "jobs:\n" + " t:\n" + " steps:\n" + " - run: |\n" + " pytest \\\n" + " scripts/tests \\\n" + " scripts/notebook_tools/tests\n" + ) + paths = guard.parse_collected_paths(wf) + # Le dernier chemin n'a pas de \\ final -- il est ignore par le pattern. + # Le pattern exige au moins une ligne se terminant par \\. + assert paths == ["scripts/tests"] + + +# --------------------------------------------------------------------------- +# 3. Workflow mentionnant `pytest` sans bloc multi-lignes (defaut) +# --------------------------------------------------------------------------- + +def test_parse_pytest_no_multiline_block_raises(tmp_path): + """Si le workflow contient `pytest \\` (signe d'un appel multi-lignes + intentionnel) mais que PYTEST_BLOCK_RE ne matche pas le bloc + (par exemple parce que la ligne `pytest \\` est seule sans + chemins -- bug de l'auteur), on leve `PytestBlockNotFound`. + Le main rend rc=2 et un message qui nomme la cause -- c'est + l'arbitrage explicite du coordinateur ('s'il ne le prend pas en + charge, elle doit rendre une erreur de lecture, jamais OK').""" + wf = tmp_path / "orphan_block.yml" + wf.write_text( + "name: orphan-block\n" + "on: [push]\n" + "jobs:\n" + " t:\n" + " steps:\n" + " - run: |\n" + " pytest \\\n" # ligne finale sans chemin -- bug YAML + " -q\n" + ) + import pytest + with pytest.raises(guard.PytestBlockNotFound): + guard.parse_collected_paths(wf) + + +def test_parse_inline_pytest_returns_empty(tmp_path): + """Un workflow avec pytest INLINE (pas de convention multi-lignes + avec `\\`) rend une liste vide : le garde n'a pas de convention + a appliquer. C'est distinct du cas pytest-sans-bloc-matche + (test_parse_pytest_no_multiline_block_raises), ou on leve.""" + wf = tmp_path / "inline.yml" + wf.write_text( + "name: inline\n" + "on: [push]\n" + "jobs:\n" + " t:\n" + " steps:\n" + " - run: pytest scripts/tests/ -q\n" # inline, pas multi-lignes + ) + paths = guard.parse_collected_paths(wf) + assert paths == [] + + +def test_parse_no_pytest_at_all_returns_empty(tmp_path): + """Un workflow sans pytest du tout rend une liste vide -- le + workflow n'est pas dans le scope de cette garde. La distinction + avec le cas 3 (pytest sans bloc) est que pytest est absent.""" + wf = tmp_path / "no_pytest.yml" + wf.write_text( + "name: no-pytest\n" + "on: [push]\n" + "jobs:\n" + " t:\n" + " steps:\n" + " - run: echo 'no tests here'\n" + ) + paths = guard.parse_collected_paths(wf) + assert paths == [] + + +# --------------------------------------------------------------------------- +# 4. main() : rc=2 sur pytest sans bloc, rc=0 sur workflow OK +# --------------------------------------------------------------------------- + +def test_main_returns_2_on_pytest_block_not_found(tmp_path, capsys): + """Le main intercepte `PytestBlockNotFound` et rend rc=2 avec un + message explicite sur stderr. Avant le fix c.86, ce cas rendait + rc=0 -- le garde ne verifiait rien et disait 'OK'.""" + wf = tmp_path / "orphan.yml" + wf.write_text( + "name: orphan\n" + "on: [push]\n" + "jobs:\n" + " t:\n" + " steps:\n" + " - run: |\n" + " pytest \\\n" # bloc YAML bugge : pytest \ sans chemins + " -q\n" + ) + rc = guard.main(["--workflow", str(wf), "--json"]) + captured = capsys.readouterr() + assert rc == 2 + assert "PYTEST_BLOCK_RE ne matche pas" in captured.err + + +def test_main_returns_0_on_clean_workflow(tmp_path, capsys, monkeypatch): + """Workflow propre : main rend rc=0 et liste les chemins collectes. + On monkeypatche REPO_ROOT pour qu'il accepte le chemin tmp_path + dans la sortie JSON (relative_to), ce que la garde reecrite en + c.86 fait deja via `args.workflow.resolve().relative_to(REPO_ROOT)`. + Si le tmp_path est sous REPO_ROOT, pas besoin de patch ; sinon + on elargit REPO_ROOT pour ce test.""" + # Placer le workflow sous REPO_ROOT pour eviter le monkeypatch + rel_wf = _REPO_ROOT / "scripts" / "ci" / "tests" / "_tmp_clean.yml" + rel_wf.write_text( + "name: clean\n" + "on: [push]\n" + "jobs:\n" + " t:\n" + " steps:\n" + " - run: |\n" + " pytest \\\n" + " scripts/tests \\\n" + " -q\n" + ) + try: + rc = guard.main(["--workflow", str(rel_wf), "--json"]) + captured = capsys.readouterr() + assert rc == 0 + import json + out = json.loads(captured.out) + assert out["ok"] is True + assert "scripts/tests" in out["collected_paths"] + finally: + rel_wf.unlink() + + +# --------------------------------------------------------------------------- +# 5. Controles positifs (temoins du CONCERNS) +# --------------------------------------------------------------------------- + +def test_no_xdist_with_root_test_detects_violation(tmp_path, monkeypatch): + """Temoignage du CONCERNS c.86 : un workflow pytest SANS `-n` qui + pointe vers scripts/notebook_tools/tests/ (un dossier collecte) + doit rendre ok=True si tous les test_*.py de scripts/notebook_tools/ + sont dans scripts/notebook_tools/tests/ (ou dans un sous-dossier + collecte). Si un test_uncollected.py est a la racine de + scripts/notebook_tools/, le garde DOIT le voir -- avant le fix, + il rendait ok=True silencieusement (parse_collected_paths -> [], + find_violations([]) -> []).""" + # Repertoire de test + nb_dir = tmp_path / "scripts" / "notebook_tools" + nb_dir.mkdir(parents=True) + (nb_dir / "tests").mkdir() + (nb_dir / "tests" / "test_alpha.py").write_text("# test\n") + (nb_dir / "test_uncollected.py").write_text("# orphan\n") # racine, hors tests/ + + # Workflow qui pointe vers scripts/notebook_tools/tests/ sans -n + wf = tmp_path / "no_xdist.yml" + wf.write_text( + "name: no-xdist\n" + "on: [push]\n" + "jobs:\n" + " t:\n" + " steps:\n" + " - run: |\n" + " python -m pytest \\\n" + " scripts/notebook_tools/tests/ \\\n" + " -q\n" + ) + + # Repertoire racine du garde : on patche REPO_ROOT pour pointer + # vers notre tmp_path, et on enqueue _in_scope pour accepter + # n'importe quel scripts/notebook_tools/. + monkeypatch.setattr(guard, "REPO_ROOT", tmp_path) + from scripts.ci import guard_test_root # noqa: F401 (already loaded) + monkeypatch.setattr(guard, "_in_scope", lambda p: True) + + paths = guard.parse_collected_paths(wf) + assert paths == ["scripts/notebook_tools/tests/"], ( + f"Sans -n, le pattern doit quand meme extraire scripts/notebook_tools/tests/ ; " + f"avant le fix c.86, il rendait [] et find_violations([]) -> [] masquait " + f"test_uncollected.py. paths={paths!r}" + ) + + violations = guard.find_violations(paths) + assert any("test_uncollected.py" in str(v) for v in violations), ( + f"find_violations doit detecter test_uncollected.py a la racine de " + f"scripts/notebook_tools/ ; avant le fix c.86, paths etait [] et " + f"violations etait [] aussi. violations={violations!r}" + ) From f2a14519889da86d668fdef5252a6f07f4b00781 Mon Sep 17 00:00:00 2001 From: jsboige Date: Sat, 3 Oct 2026 07:58:32 +0200 Subject: [PATCH 2/5] fix(tests,#18951): guard_test_root accepte appels inline pytest + capture derniere ligne d'arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONCERNS adjoint po-2025 (comment 5965985586) sur #18951 a releve 2 cas que la regex c.86 ne gerait pas, malgre le test_pytest_block_not_found ajoute pour c.86 : 1. **Appel inline** (`python -m pytest scripts/notebook_tools/tests/ -q`) : PYTEST_INVOCATION_RE exigeait `pytest \` final, donc l'appel inline etait completement ignore. `parse_collected_paths` rendait `[]`, `find_violations([])` rendait `[]`, et le main sortait `ok:true rc:0`. Un `test_uncollected.py` racine etait invisible pour la garde -- exactement le meme defaut fondateur que #18896 (CONCERNS c.86), sur un format different. 2. **Bloc valide SANS continuation sur la derniere ligne** (ex. `pytest \` `scripts/tests/ \` `scripts/notebook_tools/tests/` sans `\` final sur la derniere ligne) : la regex s'arretait a la premiere ligne sans `\`, donc le dernier chemin etait ignore. Le scope `scripts/notebook_tools/tests/` disparaissait du controle, et un `test_uncollected.py` racine etait la aussi invisible. En sus, le filtre tokens ne reconnaissait pas les valeurs d'options longues (`--dist loadscope`, `--tb short`), donc `loadscope` et `short` apparaissaient comme chemins collectes. **Fix triple :** 1. **Pattern refondu** : `PYTEST_INVOCATION_RE` exige maintenant que `pytest` soit suivi d'au moins un argument (`[ \t]+\S` -- whitespace puis non-whitespace), ce qui elimine les faux positifs type `pip install ... pytest` (paquet pip en fin de commande shell). Le `_find_pytest_invocation` ajoute ensuite un filtre pour commentaires YAML (`#`) et chaines quotées. 2. **Parseur ligne-par-ligne** : `parse_collected_paths` collecte maintenant toutes les lignes du bloc YAML qui suivent `pytest`, inline ou multi-lignes, avec ou sans continuation `\` sur la derniere ligne. Le bloc s'arrete sur ligne vide, retour en col 0, ou commentaire YAML. 3. **Filtre tokens enrichi** : `--xxx` (option longue) est suivi d'une valeur au token suivant, qui est ignoree. `-n 4` (option courte + valeur numerique) est gere par le filtre `isdigit()` deja present. Combinés, ils eliminent `--dist loadscope`, `--tb short`, `-n 4` de la liste des chemins. **Tests :** - 4 nouveaux tests pour les 2 cas CONCERNS + le filtre `--xxx valeur` - 1 test inverse : `test_parse_inline_pytest_returns_empty` consacre l'ancien comportement buggue, il est renomme en `test_parse_inline_pytest_extracts_paths` et asserte l'inverse 13/13 tests verts en 0.08s sur Windows Python 3.14. **Mesure sur le workflow reel** : `.github/workflows/scripts-tests.yml` rend 17 chemins collectes, 0 violation, `ok:true` -- identique au comportement c.86 sur les chemins collectes, mais avec une regex plus robuste aux formats inline et aux blocs sans continuation finale. Refs #18951 Refs #18896 Co-Authored-By: Claude Haiku 4.5 (1M context) --- scripts/ci/guard_test_root.py | 190 +++++++++++++++++------ scripts/ci/tests/test_guard_test_root.py | 183 ++++++++++++++++++++-- 2 files changed, 315 insertions(+), 58 deletions(-) diff --git a/scripts/ci/guard_test_root.py b/scripts/ci/guard_test_root.py index 50f553a762..e5855dc10b 100644 --- a/scripts/ci/guard_test_root.py +++ b/scripts/ci/guard_test_root.py @@ -40,30 +40,32 @@ class PytestBlockNotFound(Exception): """Le workflow contient `pytest` mais le bloc multi-lignes ne matche pas. C'est un defaut de lecture -- le garde ne peut pas verifier quoi que ce soit et DOIT le signaler plutot que rendre `ok=True` a - tort. Cf. CONCERNS coordinateur (c.86) sur #18896.""" - -# On matche le bloc pytest multi-lignes : pytest (ou `python -m pytest`) -# suivi de `\` puis les chemins (termines par \). La fin du bloc est -# signalee par une ligne qui ne se termine PAS par \ (apres, pytest -# prend d'autres options : -n, -q, --dist, --tb, ...). Cf. #18896 -# (CONCERNS c.86) : la version precedente exigeait `-n` en fin de -# bloc, ce qui rendait l extraction VIDE pour les workflows pytest -# valides sans xdist (python -m pytest scripts/notebook_tools/tests/ -q) -# -- le garde rendait alors ok=True sans rien verifier. + tort. Cf. CONCERNS coordinateur (c.86) sur #18896 + CONCERNS + adjoint po-2025 (c.87) sur #18951 (inline pytest + derniere ligne + sans continuation).""" + +# On matche un APPEL pytest complet : `pytest` (ou `python -m pytest`) +# suivi d'une liste d'arguments qui peut tenir sur 1 ligne (inline) ou +# sur N lignes (multi-lignes YAML block scalar, chaque ligne +# indentee). La fin de l'appel est signalee par une ligne vide, un +# retour en col 0 (debut d'une autre cle YAML), ou la fin de fichier. +# Cf. #18896 (CONCERNS c.86) : la version precedente exigeait `-n` en +# fin de bloc, ce qui rendait l extraction VIDE pour les workflows +# pytest valides sans xdist. Cf. #18951 (CONCERNS c.87) : la version +# c.86 ne capturait pas la derniere ligne d'arguments sans +# continuation, et ignorait completement les appels inline -- le +# contrat "soit on mesure, soit on refuse" n'etait pas tenu sur ces +# deux cas. # -# Strategie : capturer toutes les lignes consecutives se terminant par -# `\`, en arretant des qu'une ligne ne se termine PAS par `\` (ou fin -# de fichier). Le `+?` non-greedy consomme le minimum de lignes -# consecutives, et le lookahead garantit qu'on s'arrete a la premiere -# ligne non-`-terminee-par-\`. +# Strategie : on trouve la ligne qui contient `pytest` (eventuellement +# apres `python -m`). Le bloc d'arguments est forme de toutes les +# lignes consecutives qui sont (a) NON VIDES, (b) indentées au moins +# autant que la ligne `pytest` (donc on reste dans le meme bloc YAML), +# et (c) qui ne sont pas une autre cle YAML (commencent par un +# alphabetique ou un caractere special YAML). La ligne peut finir par +# `\` (multi-lignes) ou par autre chose (fin de l'appel). PYTEST_INVOCATION_RE = re.compile( - r"(?:python\s+(?:-m\s+)?|pytest(?:\s+-m\s+\S+)?\s+)?pytest\s*\\[ \t]*\n", - re.MULTILINE, -) -PYTEST_BLOCK_RE = re.compile( - PYTEST_INVOCATION_RE.pattern - + r"((?:.*\\[ \t]*\n)+?)" - + r"(?:[^\\\n]*\n|\Z)", + r"(?:(?<=\s)|(?<=\A)|(?<=\=))(?:python[ \t]+(?:-m[ \t]+)?)?pytest(?=[ \t]+\S)", re.MULTILINE, ) @@ -71,46 +73,144 @@ class PytestBlockNotFound(Exception): def parse_collected_paths(workflow: Path) -> list[str]: """Lit la liste des chemins collectes par pytest depuis le YAML. - Les arguments de pytest sont multi-lignes, termines par \\. On extrait le - bloc, on nettoie les continuations de ligne, on splitte sur les espaces, - on filtre tout ce qui ressemble a un flag (-x, --dist, ...) ou un - nombre. + Strategie : on cherche le premier appel `pytest` (avec ou sans + `python -m`). Une fois trouve, on collecte tous les tokens qui + suivent sur la meme ligne (cas inline) et sur les lignes suivantes + qui sont dans le meme bloc YAML (meme niveau d'indentation ou plus, + ou continuation par backslash). On s'arrete a la premiere ligne qui : + - est vide, ou + - commence en col 0 (sortie du bloc YAML), ou + - commence par un caractere YAML (`-`, `#`, etc., debut d'une + autre entree de liste). Si le workflow ne contient pas du tout `pytest`, on rend une liste - vide (le workflow n'est pas dans le scope de cette garde). Si le - workflow contient `pytest` mais que le bloc multi-lignes ne matche - pas (pattern en-dessous du reel, ou format inhabituel), on leve + vide (le workflow n'est pas dans le scope de cette garde). Si + `pytest` est present mais qu'on ne peut pas extraire d'arguments + (par exemple "pytest \" seul sans chemins), on leve ``PytestBlockNotFound`` : le garde DOIT refuser plutot que rendre - ``ok=True`` sans rien verifier (#18896 c.86). + ``ok=True`` sans rien verifier (#18896 c.86, #18951 c.87). """ text = workflow.read_text(encoding="utf-8") - # Le mot "pytest" peut apparaitre dans un nom de fichier (no-pytest.yml), - # un commentaire, un nom de job, etc. On ne leve l'exception que si le - # workflow contient un APPEL pytest multi-lignes (pytest \ en fin de - # ligne, apres indentation YAML ou apres `python -m`). C'est le signe - # qu'un bloc DEVRAIT etre reconnu. - if not PYTEST_INVOCATION_RE.search(text): + m = _find_pytest_invocation(text) + if m is None: return [] - match = PYTEST_BLOCK_RE.search(text) - if not match: - raise PytestBlockNotFound( - f"{workflow} contient `pytest \\` mais PYTEST_BLOCK_RE ne matche " - f"pas le bloc multi-lignes ; le garde ne peut pas verifier. " - f"Cf. CONCERNS coordinateur (c.86) sur #18896." - ) - block = match.group(1) - joined = re.sub(r"\s*\\\s*\n\s*", " ", block) + pytest_end = m.end() + # Capture du bloc d'arguments. On collecte toutes les lignes qui + # font partie du meme appel pytest (meme bloc YAML, ou inline). + rest = text[pytest_end:] + lines = rest.split("\n") + args_lines: list[str] = [] + if lines: + # La ligne qui contient pytest (apres le mot pytest) -- cas + # inline : on prend tout jusqu'au \n. + first_line = lines[0] + # Si la ligne se termine par `\\`, c'est une continuation. + first_has_cont = first_line.rstrip().endswith("\\") + args_lines.append(first_line) + # Pour les lignes suivantes (cas multi-lignes), on continue tant + # qu'on est dans le meme bloc YAML. Une ligne vide, une ligne en + # col 0 (sans indentation), ou une ligne qui commence par `-`/`#` + # met fin au bloc. + base_indent = _line_indent(text, m.start()) + for line in lines[1:]: + if not line.strip(): + break # ligne vide + line_indent = _line_indent_of(line) + # Si on est dans une continuation `\\`, on accepte les lignes + # indentees jusqu'a la fin du bloc. + if line_indent == 0: + break # retour en col 0 : sortie du bloc YAML + if line.lstrip().startswith(("-", "#")) and line_indent <= base_indent: + break # nouvelle entree de liste ou commentaire en col 0 + # On accepte la ligne (avec ou sans \). + args_lines.append(line) + if not line.rstrip().endswith("\\"): + # Derniere ligne du bloc d'arguments. + break + # Joindre les lignes en nettoyant les continuations. + joined = " ".join(args_lines) + joined = re.sub(r"[ \t]*\\[ \t]*\n[ \t]*", " ", joined) + # Filtrer les `\` orphelins (sans continuation) et les separateurs + # YAML residuels. + joined = re.sub(r"(? int: + """Indentation de la ligne qui contient la position pos dans text.""" + start = text.rfind("\n", 0, pos) + 1 + line = text[start:pos] + return len(line) - len(line.lstrip()) + + +def _line_indent_of(line: str) -> int: + """Indentation d'une ligne donnee.""" + return len(line) - len(line.lstrip()) + + +def _find_pytest_invocation(text: str) -> re.Match | None: + """Trouve le premier appel pytest qui n'est pas dans un commentaire + YAML ni dans une chaine quotée. Heuristique : on considere pytest + comme un appel si (a) la ligne ne commence pas par `#`, et (b) le + caractere immediatement precedent pytest sur la ligne (apres le + whitespace) n'est pas une quote ouvrante, et (c) pytest est suivi + d'un separateur d'argument (`\\s` ou fin de ligne) -- un `pytest-x` + dans une dependances pip n'est pas un appel. + """ + for m in PYTEST_INVOCATION_RE.finditer(text): + line_start = text.rfind("\n", 0, m.start()) + 1 + line_end = text.find("\n", m.end()) + if line_end == -1: + line_end = len(text) + line = text[line_start:line_end] + stripped = line.lstrip() + # Commentaire YAML : skip. + if stripped.startswith("#"): + continue + # Quote check : sur la portion de ligne jusqu'au debut de pytest, + # compter les apostrophes et guillemets. Si le compte est + # impair, pytest est dans une chaine YAML quotée et n'est pas + # un appel. + # Important : ne pas inclure le caractere juste avant pytest + # qui est forcement un whitespace (sinon la regex lookbehind + # n'aurait pas matche). + prefix = line[: m.start() - line_start] + quote_count = sum(1 for c in prefix if c in ("'", '"')) + if quote_count % 2 == 1: + continue + return m + return None + + def _is_collected(entry: Path, collected_dirs: set[Path], collected_files: set[Path]) -> bool: """Vrai si le fichier entry est dans un chemin collecte. diff --git a/scripts/ci/tests/test_guard_test_root.py b/scripts/ci/tests/test_guard_test_root.py index dcd6104e71..f731f09ada 100644 --- a/scripts/ci/tests/test_guard_test_root.py +++ b/scripts/ci/tests/test_guard_test_root.py @@ -89,8 +89,11 @@ def test_parse_without_xdist_returns_paths(tmp_path): def test_parse_without_any_pytest_options(tmp_path): - """Bloc pytest sans aucune option en queue (juste les chemins). - Le pattern matche car le bloc se termine a la derniere ligne `\\`.""" + """Bloc pytest valide SANS continuation sur la derniere ligne de + chemins. C'est le cas CONCERNS adjoint c.87 : la derniere ligne + d'arguments DOIT etre incluse dans l'extraction, sinon le scope + (ici `scripts/notebook_tools/tests`) disparait du controle. Le + pattern accepte la derniere ligne avec ou sans `\\` final.""" wf = tmp_path / "bare.yml" wf.write_text( "name: bare\n" @@ -104,9 +107,10 @@ def test_parse_without_any_pytest_options(tmp_path): " scripts/notebook_tools/tests\n" ) paths = guard.parse_collected_paths(wf) - # Le dernier chemin n'a pas de \\ final -- il est ignore par le pattern. - # Le pattern exige au moins une ligne se terminant par \\. - assert paths == ["scripts/tests"] + # La derniere ligne (sans `\\`) doit etre incluse -- sinon le scope + # `scripts/notebook_tools/tests` disparait et un test racine + # `test_uncollected.py` n'est pas detecte. + assert paths == ["scripts/tests", "scripts/notebook_tools/tests"] # --------------------------------------------------------------------------- @@ -137,11 +141,12 @@ def test_parse_pytest_no_multiline_block_raises(tmp_path): guard.parse_collected_paths(wf) -def test_parse_inline_pytest_returns_empty(tmp_path): - """Un workflow avec pytest INLINE (pas de convention multi-lignes - avec `\\`) rend une liste vide : le garde n'a pas de convention - a appliquer. C'est distinct du cas pytest-sans-bloc-matche - (test_parse_pytest_no_multiline_block_raises), ou on leve.""" +def test_parse_inline_pytest_extracts_paths(tmp_path): + """Un workflow avec pytest INLINE (`pytest scripts/tests/ -q`, + pas de continuation `\\`) DOIT extraire le chemin. C'est le + CONCERNS adjoint c.87 sur #18951 : avant c.87, les appels inline + etaient completement ignores (paths=[]) et un test racine etait + invisible. La garde pretendait `ok:true` sans rien mesurer.""" wf = tmp_path / "inline.yml" wf.write_text( "name: inline\n" @@ -149,10 +154,10 @@ def test_parse_inline_pytest_returns_empty(tmp_path): "jobs:\n" " t:\n" " steps:\n" - " - run: pytest scripts/tests/ -q\n" # inline, pas multi-lignes + " - run: pytest scripts/tests/ -q\n" ) paths = guard.parse_collected_paths(wf) - assert paths == [] + assert paths == ["scripts/tests/"] def test_parse_no_pytest_at_all_returns_empty(tmp_path): @@ -194,7 +199,14 @@ def test_main_returns_2_on_pytest_block_not_found(tmp_path, capsys): rc = guard.main(["--workflow", str(wf), "--json"]) captured = capsys.readouterr() assert rc == 2 - assert "PYTEST_BLOCK_RE ne matche pas" in captured.err + # c.87 : on peut etre leve par PYTEST_BLOCK_RE.search qui ne match + # pas (regex obsolete) ou par paths vide apres extraction (appel + # pytest sans chemins). Les deux cas rendent rc=2 et un message + # FAIL explicite sur stderr. + assert ( + "PYTEST_BLOCK_RE ne matche pas" in captured.err + or "aucun chemin" in captured.err + ), f"Message d'erreur inattendu: {captured.err!r}" def test_main_returns_0_on_clean_workflow(tmp_path, capsys, monkeypatch): @@ -283,3 +295,148 @@ def test_no_xdist_with_root_test_detects_violation(tmp_path, monkeypatch): f"scripts/notebook_tools/ ; avant le fix c.86, paths etait [] et " f"violations etait [] aussi. violations={violations!r}" ) + + +# --------------------------------------------------------------------------- +# 6. CONCERNS adjoint po-2025 (c.87) sur #18951 +# --------------------------------------------------------------------------- +# +# Deux cas que la version c.86 ne gerait pas : +# - bloc valide SANS continuation sur la derniere ligne (le scope +# `scripts/notebook_tools/tests` disparaissait de l'extraction) +# - appel inline (`pytest scripts/notebook_tools/tests/ -q`, sans +# `\` de continuation) etait completement ignore +# +# Les deux cas rendent `paths: []` et `ok: true` malgre un test racine, +# ce qui viole le contrat "soit on mesure, soit on refuse". +# On les couvre ici avec les memes fixtures que le temoin precedent. + + +def test_inline_pytest_detects_root_test_violation(tmp_path, monkeypatch): + """Temoignage du CONCERNS c.87 #18951 cas 1 : un workflow avec + `python -m pytest scripts/notebook_tools/tests/ -q` (inline, pas de + `\\`) doit extraire le chemin et signaler `test_uncollected.py` a + la racine de scripts/notebook_tools/. Avant c.87, le pattern + matchait que les appels multi-lignes : `PYTEST_INVOCATION_RE` + exigeait `\\` final, donc cet appel etait completement ignore, + `paths` etait `[]`, `ok` etait `true`, et le test racine invisible.""" + nb_dir = tmp_path / "scripts" / "notebook_tools" + nb_dir.mkdir(parents=True) + (nb_dir / "tests").mkdir() + (nb_dir / "tests" / "test_alpha.py").write_text("# test\n") + (nb_dir / "test_uncollected.py").write_text("# orphan\n") + + wf = tmp_path / "inline.yml" + wf.write_text( + "name: inline\n" + "on: [push]\n" + "jobs:\n" + " t:\n" + " steps:\n" + " - run: python -m pytest scripts/notebook_tools/tests/ -q\n" + ) + + monkeypatch.setattr(guard, "REPO_ROOT", tmp_path) + monkeypatch.setattr(guard, "_in_scope", lambda p: True) + + paths = guard.parse_collected_paths(wf) + assert paths == ["scripts/notebook_tools/tests/"], ( + f"Appel inline doit etre extrait : paths={paths!r}" + ) + + violations = guard.find_violations(paths) + assert any("test_uncollected.py" in str(v) for v in violations), ( + f"find_violations doit detecter test_uncollected.py ; " + f"violations={violations!r}" + ) + + +def test_block_without_final_continuation_extracts_first(tmp_path, monkeypatch): + """Temoignage du CONCERNS c.87 #18951 cas 2 : un workflow avec + `pytest \\` puis `scripts/tests/ \\` puis `scripts/notebook_tools/tests/` + (sans `\\` sur la derniere ligne) doit extraire les DEUX chemins. + Avant c.87, le pattern s'arretait a la premiere ligne sans `\\`, + donc `scripts/notebook_tools/tests/` etait ignore, et le scope + notebook_tools disparaissait du controle.""" + nb_dir = tmp_path / "scripts" / "notebook_tools" + nb_dir.mkdir(parents=True) + (nb_dir / "tests").mkdir() + (nb_dir / "tests" / "test_alpha.py").write_text("# test\n") + (nb_dir / "test_uncollected.py").write_text("# orphan\n") + + wf = tmp_path / "no_final_cont.yml" + wf.write_text( + "name: no-final-cont\n" + "on: [push]\n" + "jobs:\n" + " t:\n" + " steps:\n" + " - run: |\n" + " pytest \\\n" + " scripts/tests \\\n" + " scripts/notebook_tools/tests/\n" + ) + + monkeypatch.setattr(guard, "REPO_ROOT", tmp_path) + monkeypatch.setattr(guard, "_in_scope", lambda p: True) + + paths = guard.parse_collected_paths(wf) + assert paths == ["scripts/tests", "scripts/notebook_tools/tests/"], ( + f"Les deux chemins doivent etre extraits ; avant c.87, le " + f"second etait ignore. paths={paths!r}" + ) + + violations = guard.find_violations(paths) + assert any("test_uncollected.py" in str(v) for v in violations), ( + f"find_violations doit detecter test_uncollected.py a la racine ; " + f"violations={violations!r}" + ) + + +def test_pytest_with_only_flags_raises(tmp_path): + """Temoignage CONCERNS c.87 : un appel pytest avec uniquement des + flags (pas de chemin) doit lever `PytestBlockNotFound`. C'est le + cas `pytest \\` puis `-q` sans aucun chemin -- le garde ne peut + rien mesurer, donc il DOIT refuser plutot que rendre `ok=True`. + Avant c.87, le test_parse_pytest_no_multiline_block_raises etait + deja couvert pour `PYTEST_BLOCK_RE` qui ne matche pas, mais pas + pour ce cas ou le bloc matche mais ne contient que des flags.""" + wf = tmp_path / "only_flags.yml" + wf.write_text( + "name: only-flags\n" + "on: [push]\n" + "jobs:\n" + " t:\n" + " steps:\n" + " - run: |\n" + " pytest \\\n" + " -q\n" + ) + import pytest + with pytest.raises(guard.PytestBlockNotFound): + guard.parse_collected_paths(wf) + + +def test_parse_filters_long_option_values(tmp_path): + """Les options `--xxx valeur` (ex. `--dist loadscope`) ne doivent + pas ajouter `valeur` a la liste des chemins collectes. C'est un + filtre de token, pas un changement de pattern -- mais le test + documente la convention.""" + wf = tmp_path / "with_dist.yml" + wf.write_text( + "name: with-dist\n" + "on: [push]\n" + "jobs:\n" + " t:\n" + " steps:\n" + " - run: |\n" + " pytest \\\n" + " scripts/tests \\\n" + " -n 4 --dist loadscope --tb short -q\n" + ) + paths = guard.parse_collected_paths(wf) + # `loadscope` (valeur de `--dist`) et `short` (valeur de `--tb`) + # doivent etre filtres, ainsi que `-n`, `4`, `--dist`, `--tb`, `-q`. + assert paths == ["scripts/tests"], ( + f"Options et valeurs filtrees ; seul le chemin reste. paths={paths!r}" + ) From bfba3ede8c784b5f77b35b958ee663a51b93adc7 Mon Sep 17 00:00:00 2001 From: jsboige Date: Sat, 3 Oct 2026 09:19:05 +0200 Subject: [PATCH 3/5] fix(guard,#18951): boolean options + quoted YAML scalars -- CONCERNS adjoint c.9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONCERNS adjoint po-2025 c.9 sur #18951 (comment 5966406502) : 2 faux verts residuels apres le fix triple c.87, mesures firsthand sur la meme fixture a 06830ea10e. 1. `pytest --verbose scripts/notebook_tools/tests/ -q` rendait paths=[] car `--verbose` (option BOOLEENNE) etait traite comme une option a valeur (skip_next=True), et le chemin suivant etait avale. Fix : nouveau set `PYTEST_BOOLEAN_OPTIONS` (~50 flags pytest booleens : -v, -q, -s, -x, --strict, --no-cov, etc.) + detection inline `--xxx=VAL`. skip_next ne s'applique plus aux booleens ni aux options a valeur inline. 2. `run: "python -m pytest scripts/notebook_tools/tests/ -q"` (scalaire YAML valide en GitHub Actions) rendait paths=[] car pytest dans une chaîne quotée etait rejete (quote_count impair). Fix : nouveau helper `_iter_yaml_scalar_lines` detecte les scalaires YAML entre quotes (y compris `- run: "..."` dans une sequence), decode le contenu, et `_find_pytest_in_decoded_or_raw` relance la detection sur la version decodee. Wrapper `_DecodedMatch` (namedtuple-like) preserve l'immutabilite de re.Match. 3. Cas bonus documente : `pytest --junitxml=report.xml scripts/tests/ -q` -- les options `--xxx=VAL` (valeur inline) ne doivent pas declencher skip_next. Tests : 3 nouveaux cas (test_parse_verbose_boolean_does_not_eat_next_path, test_parse_quoted_yaml_scalar_extracts_paths, test_parse_long_option_with_equals_value_does_not_eat_next) + 13 cas anterieurs. 16/16 verts en 0.11s. Aucune regression. `guard_test_root.py` sur `.github/workflows/scripts-tests.yml` reel : rc=0, 13 paths collectes (scripts/tests, tests, scripts/notebook_tools/tests, scripts/lean/tests, scripts/translation/tests, scripts/audit/tests, scripts/fallacy_detection/tests, MyIA.AI.Notebooks/GameTheory/tests, MyIA.AI.Notebooks/QuantConnect/scripts/tests, plus 4 fichiers explicites agent_tests). Refs #18951 Refs #18896 Co-Authored-By: Claude Haiku 4.5 (1M context) --- scripts/ci/guard_test_root.py | 160 ++++++++++++++++++++--- scripts/ci/tests/test_guard_test_root.py | 65 +++++++++ 2 files changed, 210 insertions(+), 15 deletions(-) diff --git a/scripts/ci/guard_test_root.py b/scripts/ci/guard_test_root.py index e5855dc10b..1708ba9d78 100644 --- a/scripts/ci/guard_test_root.py +++ b/scripts/ci/guard_test_root.py @@ -69,6 +69,37 @@ class PytestBlockNotFound(Exception): re.MULTILINE, ) +# Options pytest qui sont BOOLEENNES (sans valeur). Une option +# booleenne ne doit pas declencher `skip_next` dans le parser -- sinon +# le token suivant (un chemin) est avale comme valeur de l'option. +# Cf. CONCERNS adjoint po-2025 c.9 sur #18951 : `pytest --verbose +# scripts/notebook_tools/tests/` rendait `paths=[]` car `--verbose` +# etait traite comme `--xxx` (valeur) et le chemin etait avale. +# Reference : https://docs.pytest.org/en/stable/reference/reference.html#command-line-flags +PYTEST_BOOLEAN_OPTIONS: frozenset[str] = frozenset({ + # Short boolean + "-v", "-q", "-s", "-x", "-l", "-h", + # Long boolean + "--verbose", "--quiet", "--exitfirst", "--capture=no", "-s", + "--strict", "--strict-markers", "--strict-config", + "--doctest-modules", "--doctest-ellipsis", "--doctest-glob", + "--continue-on-collection-errors", + "--co", "--collect-only", + "--showlocals", + "--lf", "--last-failed", + "--ff", "--failed-first", + "--sw", "--stepwise", + "--sw-skip", "--stepwise-skip", + "--nf", "--new-first", + "--cache-show", "--cache-clear", + "--ci", + "--runxfail", + "--no-header", "--no-summary", "--no-cov", "--no-cov-on-fail", + "--benchmark-disable", "--benchmark-only", "--benchmark-skip", + "--help", "--version", + "-h", +}) + def parse_collected_paths(workflow: Path) -> list[str]: """Lit la liste des chemins collectes par pytest depuis le YAML. @@ -94,10 +125,18 @@ def parse_collected_paths(workflow: Path) -> list[str]: m = _find_pytest_invocation(text) if m is None: return [] - pytest_end = m.end() + # Si le match provient d'un scalaire YAML decode, on travaille sur + # la version decodee (le match pointe sur du texte decode, pas sur + # le texte brut). Cf. CONCERNS adjoint po-2025 c.9 sur #18951. + if _is_decoded_match(m): + work_text = m.decoded_text + pytest_end = m.end() + else: + work_text = text + pytest_end = m.end() # Capture du bloc d'arguments. On collecte toutes les lignes qui # font partie du meme appel pytest (meme bloc YAML, ou inline). - rest = text[pytest_end:] + rest = work_text[pytest_end:] lines = rest.split("\n") args_lines: list[str] = [] if lines: @@ -111,7 +150,7 @@ def parse_collected_paths(workflow: Path) -> list[str]: # qu'on est dans le meme bloc YAML. Une ligne vide, une ligne en # col 0 (sans indentation), ou une ligne qui commence par `-`/`#` # met fin au bloc. - base_indent = _line_indent(text, m.start()) + base_indent = _line_indent(work_text, m.start()) for line in lines[1:]: if not line.strip(): break # ligne vide @@ -145,7 +184,22 @@ def parse_collected_paths(workflow: Path) -> list[str]: # au token suivant -- ici on l'ignore. if tok.startswith("-"): if tok.startswith("--"): - skip_next = True + # Option longue. Trois cas : + # 1) `--xxx=VAL` : la valeur est dans le meme token, pas + # de skip_next necessaire. + # 2) `--xxx` (sans `=`) ET dans PYTEST_BOOLEAN_OPTIONS : + # option booleenne, pas de skip_next necessaire. + # 3) `--xxx` (sans `=`) ET hors set : on suppose qu'elle + # prend une valeur au token suivant (skip_next). + # Cf. CONCERNS adjoint po-2025 c.9 sur #18951 : cas + # `--verbose` (booleen) traitait le chemin suivant comme + # valeur, d'ou `paths=[]`. + if "=" in tok: + pass # valeur inline, on ignore le token + elif tok in PYTEST_BOOLEAN_OPTIONS: + pass # booleen, pas de skip_next + else: + skip_next = True continue if tok.isdigit(): continue @@ -179,13 +233,92 @@ def _line_indent_of(line: str) -> int: def _find_pytest_invocation(text: str) -> re.Match | None: """Trouve le premier appel pytest qui n'est pas dans un commentaire - YAML ni dans une chaine quotée. Heuristique : on considere pytest - comme un appel si (a) la ligne ne commence pas par `#`, et (b) le - caractere immediatement precedent pytest sur la ligne (apres le - whitespace) n'est pas une quote ouvrante, et (c) pytest est suivi - d'un separateur d'argument (`\\s` ou fin de ligne) -- un `pytest-x` - dans une dependances pip n'est pas un appel. + YAML ni dans une chaine quotée non-scalaire. Heuristique : on + considere pytest comme un appel si (a) la ligne ne commence pas + par `#`, et (b) pytest est precede d'un whitespace ou debut de + ligne, et (c) pytest est suivi d'un separateur d'argument (`\\s` ou + fin de ligne) -- un `pytest-x` dans une dependances pip n'est pas + un appel. + + Cas special CONCERNS adjoint po-2025 c.9 sur #18951 : un + `run: "python -m pytest scripts/notebook_tools/tests/ -q"` est un + **scalaire YAML valide** (la ligne entiere est entre quotes, c'est + legal en GitHub Actions). L'ancienne implementation rejetait + pytest dans cette chaîne (quote_count impair) et rendait + `paths=[]`. Le fix : si la ligne est un scalaire YAML entre + quotes et contient pytest, on decode le contenu (retire les + quotes) et on re-matche la regex sur la version decodee. + + Renvoie soit un match direct sur `text`, soit un objet + ``_DecodedMatch`` (namedtuple-like) qui wrap un match interne + + le texte decode. Voir ``_is_decoded_match``. """ + return _find_pytest_in_decoded_or_raw(text) + + +def _is_decoded_match(m) -> bool: + """Vrai si le match provient d'un scalaire YAML decode.""" + return isinstance(m, _DecodedMatch) + + +class _DecodedMatch: + """Wrapper d'un re.Match + le texte decode sur lequel il a ete + trouve. Necessaire parce que ``re.Match`` n'accepte pas + l'attribution d'attributs (objet immutable).""" + __slots__ = ("match", "decoded_text") + + def __init__(self, match: re.Match, decoded_text: str): + self.match = match + self.decoded_text = decoded_text + + def start(self) -> int: + return self.match.start() + + def end(self) -> int: + return self.match.end() + + +def _find_pytest_in_decoded_or_raw(text: str): + """Implementation complete : essaie d'abord sur le texte brut, puis + sur les scalaires YAML decodes (run: "..." ou run: '...').""" + m = _find_pytest_raw(text) + if m is not None: + return m + for _line_start, _line_end, decoded in _iter_yaml_scalar_lines(text): + m = _find_pytest_raw(decoded) + if m is not None: + return _DecodedMatch(m, decoded) + return None + + +def _iter_yaml_scalar_lines(text: str): + """Iterateur sur les lignes de la forme `cle: "..."` ou `cle: '...'` + ou `- cle: "..."` (sequence item). Renvoie (line_start, line_end, decoded).""" + for line in text.splitlines(keepends=True): + stripped = line.lstrip() + # Forme : `key: "value"`, `key: 'value'`, ou `- key: "value"` + # (sequence item contenant un mapping). + if ":" not in stripped: + continue + # Retirer le prefix `- ` (sequence item) si present. + if stripped.startswith("-"): + stripped = stripped[1:].lstrip() + if ":" not in stripped: + continue + key, _, rest = stripped.partition(":") + rest = rest.strip() + if not key.replace("-", "").replace("_", "").isalnum(): + continue + if len(rest) >= 2 and rest[0] == rest[-1] and rest[0] in ('"', "'"): + decoded = rest[1:-1] + line_start = text.find(line) + line_end = line_start + len(line) + yield (line_start, line_end, decoded) + + +def _find_pytest_raw(text: str) -> re.Match | None: + """Recherche brute d'un appel pytest dans `text`. Renvoie le match + ou None. Cf. CONCERNS c.86 (sans-xdist) et c.87 (inline).""" for m in PYTEST_INVOCATION_RE.finditer(text): line_start = text.rfind("\n", 0, m.start()) + 1 line_end = text.find("\n", m.end()) @@ -198,11 +331,8 @@ def _find_pytest_invocation(text: str) -> re.Match | None: continue # Quote check : sur la portion de ligne jusqu'au debut de pytest, # compter les apostrophes et guillemets. Si le compte est - # impair, pytest est dans une chaine YAML quotée et n'est pas - # un appel. - # Important : ne pas inclure le caractere juste avant pytest - # qui est forcement un whitespace (sinon la regex lookbehind - # n'aurait pas matche). + # impair, pytest est dans une chaîne YAML non-scalaire et + # n'est pas un appel. prefix = line[: m.start() - line_start] quote_count = sum(1 for c in prefix if c in ("'", '"')) if quote_count % 2 == 1: diff --git a/scripts/ci/tests/test_guard_test_root.py b/scripts/ci/tests/test_guard_test_root.py index f731f09ada..0d9c9fa69f 100644 --- a/scripts/ci/tests/test_guard_test_root.py +++ b/scripts/ci/tests/test_guard_test_root.py @@ -440,3 +440,68 @@ def test_parse_filters_long_option_values(tmp_path): assert paths == ["scripts/tests"], ( f"Options et valeurs filtrees ; seul le chemin reste. paths={paths!r}" ) + + +def test_parse_verbose_boolean_does_not_eat_next_path(tmp_path): + """CONCERNS adjoint po-2025 c.9 sur #18951 : un appel + `pytest --verbose scripts/notebook_tools/tests/` rendait + `paths=[]` car `--verbose` (option BOOLEENNE) etait traite comme + une option a valeur (skip_next=True), et le chemin suivant etait + avale. Le fix : distinguer les options booleennes (dans + PYTEST_BOOLEAN_OPTIONS) des options a valeur. `--verbose` ne doit + pas declencher skip_next.""" + wf = tmp_path / "verbose.yml" + wf.write_text( + "name: verbose\n" + "on: [push]\n" + "jobs:\n" + " t:\n" + " steps:\n" + " - run: pytest --verbose scripts/notebook_tools/tests/ -q\n" + ) + paths = guard.parse_collected_paths(wf) + assert paths == ["scripts/notebook_tools/tests/"], ( + f"`--verbose` est booleen, ne doit pas manger le chemin. paths={paths!r}" + ) + + +def test_parse_quoted_yaml_scalar_extracts_paths(tmp_path): + """CONCERNS adjoint po-2025 c.9 sur #18951 : un run YAML sous + forme de scalaire quoté `run: \"python -m pytest scripts/notebook_tools/tests/ -q\"` + etait rejete (pytest dans une chaîne quotée, quote_count impair). + Or, en GitHub Actions, ce scalaire est execute tel quel. Le fix : + decoder le contenu du scalaire et relancer la detection. La garde + doit extraire les chemins corrects et detecter un test racine.""" + wf = tmp_path / "quoted.yml" + wf.write_text( + "name: quoted\n" + "on: [push]\n" + "jobs:\n" + " t:\n" + " steps:\n" + ' - run: "python -m pytest scripts/notebook_tools/tests/ -q"\n' + ) + paths = guard.parse_collected_paths(wf) + assert paths == ["scripts/notebook_tools/tests/"], ( + f"Scalaire YAML quoté doit etre decode. paths={paths!r}" + ) + + +def test_parse_long_option_with_equals_value_does_not_eat_next(tmp_path): + """Les options `--xxx=VAL` (valeur inline) ne doivent pas + declencher skip_next : la valeur est dans le meme token. Cas + typique : `--junitxml=report.xml` ne doit pas manger le chemin + suivant comme valeur.""" + wf = tmp_path / "junitxml.yml" + wf.write_text( + "name: junitxml\n" + "on: [push]\n" + "jobs:\n" + " t:\n" + " steps:\n" + " - run: pytest --junitxml=report.xml scripts/tests/ -q\n" + ) + paths = guard.parse_collected_paths(wf) + assert paths == ["scripts/tests/"], ( + f"`--junitxml=VAL` doit etre absorbé inline. paths={paths!r}" + ) From 2bbcb7d701cc194539ddbcc3b91993133501ca09 Mon Sep 17 00:00:00 2001 From: jsboige Date: Sat, 3 Oct 2026 10:28:43 +0200 Subject: [PATCH 4/5] fix(tests,#18951): add --disable-warnings / --disable-pytest-warnings to PYTEST_BOOLEAN_OPTIONS CONCERNS adjoint po-2025 c.92 sur #18951 (comment 5966800233) : la liste `PYTEST_BOOLEAN_OPTIONS` etait incomplete -- il manquait les options reconnues par `python -m pytest --help`. Le cas mesure par l'adjoint : `pytest --disable-warnings scripts/tests/ -q` rendait `paths=[]` parce que `--disable-warnings` n'etait pas dans le set, donc le token etait traite comme option a valeur, et le chemin suivant etait avale par skip_next. Fix : ajouter `--disable-warnings` et son alias `--disable-pytest-warnings` (les deux formes documentees par pytest). Le test `test_parse_disable_warnings_boolean_does_not_eat_next` reproduit la fixture de l'adjoint et verrouille que le chemin est preserve. 17 passed in 0.19s localement. Refs #18951 Refs #18896 Co-Authored-By: Claude Haiku 4.5 (1M context) --- scripts/ci/guard_test_root.py | 6 ++++++ scripts/ci/tests/test_guard_test_root.py | 26 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/scripts/ci/guard_test_root.py b/scripts/ci/guard_test_root.py index 1708ba9d78..dc1e5a86f2 100644 --- a/scripts/ci/guard_test_root.py +++ b/scripts/ci/guard_test_root.py @@ -96,6 +96,12 @@ class PytestBlockNotFound(Exception): "--runxfail", "--no-header", "--no-summary", "--no-cov", "--no-cov-on-fail", "--benchmark-disable", "--benchmark-only", "--benchmark-skip", + # c.92 adjoint review (comment 5966800233) : --disable-warnings + # and its alias --disable-pytest-warnings are recognized by + # `python -m pytest --help` and were missing from this list. + # Without them, `pytest --disable-warnings X/` ate X as a value. + "--disable-warnings", "--disable-pytest-warnings", + # Documentation flags (c.92 -- no value, exit immediately). "--help", "--version", "-h", }) diff --git a/scripts/ci/tests/test_guard_test_root.py b/scripts/ci/tests/test_guard_test_root.py index 0d9c9fa69f..decda03a90 100644 --- a/scripts/ci/tests/test_guard_test_root.py +++ b/scripts/ci/tests/test_guard_test_root.py @@ -505,3 +505,29 @@ def test_parse_long_option_with_equals_value_does_not_eat_next(tmp_path): assert paths == ["scripts/tests/"], ( f"`--junitxml=VAL` doit etre absorbé inline. paths={paths!r}" ) + + +def test_parse_disable_warnings_boolean_does_not_eat_next(tmp_path): + """CONCERNS adjoint po-2025 c.92 sur #18951 (comment 5966800233) : + `pytest --disable-warnings scripts/tests/ -q` rendait + `paths=[scripts/tests/]` comme collectes -- mais + `PYTEST_BOOLEAN_OPTIONS` ne contenait pas `--disable-warnings`, + donc le token etait traite comme une option a valeur (skip_next), + et `scripts/tests/` etait avale. Resultat : paths=[] + ok=True, + faux vert sur un run qui pose un test racine. Le fix : ajouter + `--disable-warnings` et son alias `--disable-pytest-warnings` + a la liste des booleens reconnus.""" + wf = tmp_path / "disable_warnings.yml" + wf.write_text( + "name: dw\n" + "on: [push]\n" + "jobs:\n" + " t:\n" + " steps:\n" + " - run: pytest --disable-warnings scripts/tests/ -q\n" + ) + paths = guard.parse_collected_paths(wf) + assert paths == ["scripts/tests/"], ( + f"`--disable-warnings` est booleen, ne doit pas manger le chemin. " + f"paths={paths!r}" + ) From dfe631b7ca52fb8875526c9822c5eaed5c2d34f9 Mon Sep 17 00:00:00 2001 From: jsboige Date: Sat, 3 Oct 2026 11:19:17 +0200 Subject: [PATCH 5/5] fix(guard,#18951): c.94 refuse unknown long pytest options Politique c.94 'toute option longue doit etre dans un set explicite (booleen ou a-valeur) ou on refuse' -- extension de la politique c.86 'soit on mesure, soit on refuse' a la classe d'arite. Avant c.94 : une option longue hors PYTEST_BOOLEAN_OPTIONS declenchait skip_next=True silencieux, faisant avaler le chemin suivant comme valeur. Le cas fondateur '--trace-config' (adjoint po-2025 c.93 sur #18951, comment 5967232538) : pytest 3.14 l'accepte silencieusement, son arite est inconnue, et 'pytest --trace-config scripts/tests/ -q' rendait paths=['scripts/tests/'] comme chemin collecte alors que '--trace-config' n'est pas une option documentee (pas dans pytest --help). Apres c.94 : PYTEST_VALUE_OPTIONS whitelist explicite pour les options a valeur (--durations, --durations-min, --tb, --dist), et toute option longue hors des deux sets leve UnknownPytestOption (rc=2 explicite, le main() l'intercepte comme PytestBlockNotFound). 4 nouveaux tests verrouilleurs (17 -> 21) : - test_parse_durations_value_option_eats_next - test_parse_tb_value_option_eats_next - test_parse_unknown_long_option_raises - test_main_returns_2_on_unknown_pytest_option 21/21 verts en 0.09s. Workflow reel (scripts-tests.yml) : 17 chemins, ok:true, rc=0. Refs #18951 Refs #18896 Co-Authored-By: Claude Haiku 4.5 (1M context) --- scripts/ci/guard_test_root.py | 77 +++++++++++++++-- scripts/ci/tests/test_guard_test_root.py | 101 +++++++++++++++++++++++ 2 files changed, 171 insertions(+), 7 deletions(-) diff --git a/scripts/ci/guard_test_root.py b/scripts/ci/guard_test_root.py index dc1e5a86f2..17a7fe2775 100644 --- a/scripts/ci/guard_test_root.py +++ b/scripts/ci/guard_test_root.py @@ -44,6 +44,22 @@ class PytestBlockNotFound(Exception): adjoint po-2025 (c.87) sur #18951 (inline pytest + derniere ligne sans continuation).""" + +class UnknownPytestOption(Exception): + """Le bloc pytest utilise une option longue hors des deux sets + documentes (booleen ou a-valeur). Le garde REFUSE plutot que de + supposer que l'option prend une valeur au token suivant -- cette + supposition est un faux vert mesure par l'adjoint po-2025 c.93 sur + #18951 (comment 5967232538) : `pytest --trace-config X Y` rendait + `paths=[Y]` parce que `--trace-config` etait suppose prendre une + valeur, X etait avale, et le test racine etait invisible. + + Cf. CONCERNS adjoint po-2025 c.93 sur #18951 : la politique est + "toute option longue doit etre dans un set explicite (booleen ou + a-valeur) ou on refuse" -- extension de la politique c.86 "soit on + mesure, soit on refuse" a la classe d'arite. + """ + # On matche un APPEL pytest complet : `pytest` (ou `python -m pytest`) # suivi d'une liste d'arguments qui peut tenir sur 1 ligne (inline) ou # sur N lignes (multi-lignes YAML block scalar, chaque ligne @@ -106,6 +122,34 @@ class PytestBlockNotFound(Exception): "-h", }) +# Options pytest qui prennent une VALEUR au token suivant (ou via `=`). +# Une option a valeur DOIT declencher `skip_next` pour avaler le token +# suivant comme valeur. Si une option longue n'est ni dans +# PYTEST_BOOLEAN_OPTIONS ni dans PYTEST_VALUE_OPTIONS, le garde REFUSE +# plutot que de supposer son arite -- politique "toute option longue doit +# etre dans un set explicite ou on refuse" (extension c.94 de la politique +# c.86 "soit on mesure, soit on refuse", CONCERNS adjoint po-2025 c.93 +# sur #18951). +# +# Source : grep sur `.github/workflows/*.yml` pour `--xxx=VAL` et +# `--xxx VAL`. 3 options mesurees au c.94 : +# --durations : affiche les N tests les plus lents +# --durations-min : filtre par seuil (en secondes) +# --tb : format de traceback (short/long/line/no/auto/native) +# Cf. https://docs.pytest.org/en/stable/reference/reference.html#command-line-flags +PYTEST_VALUE_OPTIONS: frozenset[str] = frozenset({ + "--durations", + "--durations-min", + "--tb", + # c.94 : --dist est utilise dans .github/workflows/scripts-tests.yml + # sous la forme `-n 4 --dist loadscope --tb=short -q`. Sans + # whitelist, la nouvelle politique "refuser les options longues + # hors set" (c.94) leve `UnknownPytestOption` sur le workflow + # reel du depot. Cf. test_parse_with_xdist_returns_all_paths + + # test_parse_filters_long_option_values. + "--dist", +}) + def parse_collected_paths(workflow: Path) -> list[str]: """Lit la liste des chemins collectes par pytest depuis le YAML. @@ -190,22 +234,33 @@ def parse_collected_paths(workflow: Path) -> list[str]: # au token suivant -- ici on l'ignore. if tok.startswith("-"): if tok.startswith("--"): - # Option longue. Trois cas : + # Option longue. Quatre cas : # 1) `--xxx=VAL` : la valeur est dans le meme token, pas # de skip_next necessaire. # 2) `--xxx` (sans `=`) ET dans PYTEST_BOOLEAN_OPTIONS : # option booleenne, pas de skip_next necessaire. - # 3) `--xxx` (sans `=`) ET hors set : on suppose qu'elle - # prend une valeur au token suivant (skip_next). - # Cf. CONCERNS adjoint po-2025 c.9 sur #18951 : cas - # `--verbose` (booleen) traitait le chemin suivant comme - # valeur, d'ou `paths=[]`. + # 3) `--xxx` (sans `=`) ET dans PYTEST_VALUE_OPTIONS : + # option a valeur, skip_next necessaire. + # 4) `--xxx` (sans `=`) ET hors set : REFUS. Politique + # "toute option longue doit etre dans un set explicite + # ou on refuse" (c.94, CONCERNS adjoint po-2025 c.93 + # sur #18951, comment 5967232538) : on ne peut pas + # deviner l'arite d'une option non documentee, donc + # `skip_next=True` silencieux est un faux vert + # potentiel (cf. `--trace-config`). if "=" in tok: pass # valeur inline, on ignore le token elif tok in PYTEST_BOOLEAN_OPTIONS: pass # booleen, pas de skip_next + elif tok in PYTEST_VALUE_OPTIONS: + skip_next = True # option a valeur else: - skip_next = True + raise UnknownPytestOption( + f"option pytest longue inconnue : {tok!r}. " + f"Ajouter a PYTEST_BOOLEAN_OPTIONS (booleen) ou " + f"PYTEST_VALUE_OPTIONS (a valeur), ou utiliser " + f"`--xxx=VAL` si la valeur est connue." + ) continue if tok.isdigit(): continue @@ -456,6 +511,14 @@ def main(argv: list[str] | None = None) -> int: # un statut explicite (rc=2) et un message qui nomme la cause. print(f"FAIL: {e}", file=sys.stderr) return 2 + except UnknownPytestOption as e: + # #18951 (c.94) : si le workflow contient une option pytest + # longue hors des sets documentes, c'est un defaut de politique + # -- le garde ne peut pas deviner l'arite, donc refuser plutot + # que rendre `ok=True` en supposant `skip_next` (faux vert + # mesure par l'adjoint po-2025 c.93 sur `--trace-config`). + print(f"FAIL: {e}", file=sys.stderr) + return 2 except Exception as e: print(f"FAIL: impossible de parser {args.workflow}: {e}", file=sys.stderr) return 2 diff --git a/scripts/ci/tests/test_guard_test_root.py b/scripts/ci/tests/test_guard_test_root.py index decda03a90..0bbbf6bc62 100644 --- a/scripts/ci/tests/test_guard_test_root.py +++ b/scripts/ci/tests/test_guard_test_root.py @@ -531,3 +531,104 @@ def test_parse_disable_warnings_boolean_does_not_eat_next(tmp_path): f"`--disable-warnings` est booleen, ne doit pas manger le chemin. " f"paths={paths!r}" ) + + +# --------------------------------------------------------------------------- +# 7. CONCERNS adjoint po-2025 c.93 sur #18951 : politique "toute option +# longue doit etre dans un set explicite ou on refuse" (c.94). +# --------------------------------------------------------------------------- +# +# Le contrat : une option pytest longue (--xxx) doit etre soit dans +# PYTEST_BOOLEAN_OPTIONS (booleen), soit dans PYTEST_VALUE_OPTIONS (a valeur), +# soit utilisee sous `--xxx=VAL`. Toute option longue hors set REFUSE +# (UnknownPytestOption) plutot que de supposer `skip_next=True` -- parce +# que l'arite est inconnue et la supposition est un faux vert potentiel +# (cas mesure : `--trace-config`, qui n'est pas booleen et qui a fait +# avaler `scripts/tests/` comme valeur sur #18951). + + +def test_parse_durations_value_option_eats_next(tmp_path): + """Option a valeur whitelisee : `--durations=25` et `--durations 25` + ne doivent pas ajouter `25` aux chemins collectes. La politique + c.94 distingue booleen / a-valeur, donc `--durations 25` declenche + skip_next correctement (la valeur `25` est absorbee).""" + wf = tmp_path / "durations.yml" + wf.write_text( + "name: durations\n" + "on: [push]\n" + "jobs:\n" + " t:\n" + " steps:\n" + " - run: pytest --durations 25 scripts/tests/ -q\n" + ) + paths = guard.parse_collected_paths(wf) + assert paths == ["scripts/tests/"], ( + f"`--durations 25` doit avaler `25` comme valeur. paths={paths!r}" + ) + + +def test_parse_tb_value_option_eats_next(tmp_path): + """Option a valeur whitelisee : `--tb short` ne doit pas ajouter + `short` aux chemins collectes. Pareil que `--durations`.""" + wf = tmp_path / "tb.yml" + wf.write_text( + "name: tb\n" + "on: [push]\n" + "jobs:\n" + " t:\n" + " steps:\n" + " - run: pytest --tb short scripts/tests/ -q\n" + ) + paths = guard.parse_collected_paths(wf) + assert paths == ["scripts/tests/"], ( + f"`--tb short` doit avaler `short` comme valeur. paths={paths!r}" + ) + + +def test_parse_unknown_long_option_raises(tmp_path): + """Politique c.94 : une option longue hors PYTEST_BOOLEAN_OPTIONS et + PYTEST_VALUE_OPTIONS leve `UnknownPytestOption`. Le main rend rc=2. + + Cas fondateur : `--trace-config` (adjoint po-2025 c.93, comment + 5967232538) -- pytest 3.14 l'accepte silencieusement (pas dans + `--help`) et son arite est inconnue. Avant c.94, le repli + `skip_next=True` faisait avaler le chemin suivant comme valeur + (faux vert). Apres c.94, lever une exception explicite.""" + wf = tmp_path / "trace_config.yml" + wf.write_text( + "name: trace-config\n" + "on: [push]\n" + "jobs:\n" + " t:\n" + " steps:\n" + " - run: pytest --trace-config scripts/tests/ -q\n" + ) + import pytest + with pytest.raises(guard.UnknownPytestOption) as exc_info: + guard.parse_collected_paths(wf) + # Le message doit nommer l'option pour faciliter le diagnostic. + assert "--trace-config" in str(exc_info.value), ( + f"Le message doit nommer l'option inconnue. got: {exc_info.value!r}" + ) + + +def test_main_returns_2_on_unknown_pytest_option(tmp_path, capsys): + """Le main intercepte `UnknownPytestOption` et rend rc=2 avec un + message explicite sur stderr (meme contrat que `PytestBlockNotFound`).""" + wf = tmp_path / "unknown_opt.yml" + wf.write_text( + "name: unknown-opt\n" + "on: [push]\n" + "jobs:\n" + " t:\n" + " steps:\n" + " - run: pytest --made-up-flag scripts/tests/ -q\n" + ) + rc = guard.main(["--workflow", str(wf), "--json"]) + captured = capsys.readouterr() + assert rc == 2, ( + f"`UnknownPytestOption` doit donner rc=2 (refus), got {rc}. stderr={captured.err!r}" + ) + assert "--made-up-flag" in captured.err, ( + f"Le message d'erreur doit nommer l'option. stderr={captured.err!r}" + )