From 2cc4ff5755c9ec20016e91b8905b19f284062f64 Mon Sep 17 00:00:00 2001 From: jsboige Date: Mon, 5 Oct 2026 00:43:08 +0200 Subject: [PATCH] fix(ci,#19168): absorber TRANCHE15/16 et declarer l'ombre du lot pilote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux gardes declarees `blocking=True` ne bloquaient rien : le job « Always-on guards » lance `fast_lane.py --shadow`, et le moteur calcule `effective_shadow = args.shadow and not guard.absorbed`. Sans absorption, elles emettaient sous `fast-lane (ombre): ` avec une conclusion NEUTRE et n'entraient pas dans `blocking_failed`. Absorbees, apres verification qu'elles sont vertes sur `main` (donc sans rougir aucune PR existante par dette heritee) : - `lake-direct-invocation-guard` (`--all --check` -> rc=0, 6 fichiers en dette tous allowlistes) ; - `control-chars-in-cells-guard` (`--diff origin/main...HEAD` -> rc=0). Temoin du basculement, meme garde et `rc=1` : avant : nom `fast-lane (ombre): control-chars-in-cells-guard`, conclusion `neutral`, hors `blocking_failed` apres : nom `control-chars-in-cells-guard`, conclusion `failure`, compte dans `blocking_failed` C'est exactement le critere de cloture de l'issue (nom canonique sur une PR de notebooks, et un diff violant la garde rougit « Always-on guards »). Declare l'ombre du lot pilote. Mesure du 2026-10-05 sur les 15 gardes pilotes bloquantes non absorbees : - 9 ont un workflow d'origine qui declenche encore sur `pull_request` : c'est lui qui bloque, la voie rapide observe ; - 2 ont un workflow d'origine qui ne declenche PAS sur `pull_request` (`perimeter-review-guard`, `self-hosted-runner-policy`) : aucun autre emetteur de leur nom de check-run ; - 4 sont natives de la voie rapide : meme situation, sans workflow. Les deux dernieres categories sont donc inertes aujourd'hui -- declarees ici avec leur critere de bascule, pas reparees : basculer le lot pilote entier est le geste du programme #12567, pas celui de cette correction. `shadow_reason` (nouveau champ de `Guard`) porte le motif ET le critere de bascule, ce que l'issue demande comme seconde voie. Le test `test_aucun_garde_bloquant_n_est_inert_sans_declaration` ferme la classe entiere : tout garde bloquant doit etre absorbe ou declare, et un absorbe qui porte encore un motif d'ombre est signale comme declaration perimee. Defaut d'isolation revele et repare au passage : `_drive_mixed_emission` ne vidait que PILOT, TRANCHE1, 2, 4 et 5 -- les gardes REELLES de douze tranches tournaient dans un test qui annonce une lane a deux gardes, avec leur rc injecte par le faux `run_argv`. Le helper vide desormais toute tranche decouverte dynamiquement. Preuves : - `pytest scripts/tests/test_fast_lane.py scripts/tests/test_check_absorbed_ check_run_identity.py scripts/tests/test_pr_gate.py scripts/tests/test_check_ control_chars_in_cells.py scripts/tests/test_fast_lane_merge_base.py -q` -> 250 passed - falsifiabilite : retirer `absorbed=True` de TRANCHE16 rougit le nouveau test, qui nomme `control-chars-in-cells-guard` ; fichier restaure depuis backup et verifie byte-identique - `check_absorbed_check_run_identity.py --check` -> rc=0 (16 gardes absorbes byte-identiques) Co-Authored-By: Claude Haiku 4.5 (1M context) --- scripts/ci/fast_lane_registry.py | 63 +++++++++++++++++++++++++++ scripts/tests/test_fast_lane.py | 74 ++++++++++++++++++++++++++++++-- 2 files changed, 134 insertions(+), 3 deletions(-) diff --git a/scripts/ci/fast_lane_registry.py b/scripts/ci/fast_lane_registry.py index 70b27fd17b..fd00ebd62b 100644 --- a/scripts/ci/fast_lane_registry.py +++ b/scripts/ci/fast_lane_registry.py @@ -80,6 +80,15 @@ class Guard: swap_paths: list[str] = field(default_factory=list) iterates_paths: bool = False # voir `run_iter` dans fast_lane.py absorbed: bool = False # tranche d'absorption #12567 : nom canonique + conclusion reelle, meme en lane ombre + # Motif d'ombre DECLARE (#19168). Un garde `blocking=True` qui n'est pas + # absorbe n'est pas compte par `blocking_failed` et emet sous + # `fast-lane (ombre): ` avec une conclusion neutre : son `blocking=True` + # est alors une declaration sans effet. Deux etats sont legitimes -- + # absorbe (il bloque vraiment), ou volontairement en ombre, et dans ce + # second cas le motif ET le critere de bascule s'ecrivent ICI. Sans cette + # declaration, la classe entiere est invisible : `test_fast_lane.py` + # rougit des qu'un garde bloquant n'est ni absorbe ni declare. + shadow_reason: str = "" # Codes de retour traites comme SUCCES au-dela de 0. Les detecteurs de # la serie figure/texte rendent rc=1 sur defaut et rc=2 sur fichier # INTROUVABLE (mesure firsthand : un JSON corrompu rend rc=0 avec une @@ -131,6 +140,31 @@ class Guard: NOTEBOOK_GLOBS = ["**/*.ipynb"] # --------------------------------------------------------------------------- +# Motifs d'ombre declares du lot pilote (#19168). Trois situations distinctes, +# mesurees le 2026-10-05, et non des variantes de redaction : +# - 9 gardes ont un workflow d'origine qui declenche encore sur `pull_request` : +# le blocage est porte par lui, la voie rapide ne fait qu'observer ; +# - 2 gardes ont un workflow d'origine qui ne declenche PAS sur `pull_request` +# (`perimeter-review-guard`, `self-hosted-runner-policy`) : aucun autre +# emetteur de leur nom de check-run ; +# - 4 gardes sont natives de la voie rapide : meme situation, sans workflow. +# Les deux dernieres categories sont donc inertes aujourd'hui -- c'est declare +# ici, pas repare, parce que la bascule du lot pilote entier est le geste du +# programme #12567 et non celui de cette correction. +PILOT_SHADOW_WORKFLOW_ENCORE_ACTIF = ( + "phase pilote #11835 : le workflow d'origine porte encore le declencheur " + "`pull_request`, c'est lui qui bloque ; la voie rapide observe. Critere de " + "bascule : absorber quand ce declencheur sera retire (programme #12567).") +PILOT_SHADOW_SANS_EMETTEUR = ( + "phase pilote #11835, aucun autre emetteur : le workflow d'origine ne " + "declenche pas sur `pull_request`. Critere de bascule : absorber, ou retirer " + "la garde.") +PILOT_SHADOW_NATIF = ( + "phase pilote #11835, garde natif : aucun workflow d'origine, donc aucun autre " + "emetteur du nom de check-run. Critere de bascule : absorber (convention des " + "tranches depuis TRANCHE8).") + + # Lot pilote (#11835). Dix gardes choisis pour couvrir les formes que le # moteur doit savoir traiter, et non pour leur nombre : # @@ -170,6 +204,7 @@ class Guard: "--scan-all", "--check", "--exclude-submodules", ], blocking=True, + shadow_reason=PILOT_SHADOW_WORKFLOW_ENCORE_ACTIF, ), Guard( name="pip-leak-guard", @@ -182,6 +217,7 @@ class Guard: swap_paths=["MyIA.AI.Notebooks"], blocking=True, needs_base=True, + shadow_reason=PILOT_SHADOW_WORKFLOW_ENCORE_ACTIF, ), Guard( name="solution-leak-guard", @@ -204,6 +240,7 @@ class Guard: # le stock ne rougit personne (lignes AJOUTEES # seules), une PR qui rouvre la veine rougit needs_base=True, + shadow_reason=PILOT_SHADOW_WORKFLOW_ENCORE_ACTIF, ), Guard( name="perimeter-review-guard", @@ -212,6 +249,7 @@ class Guard: argv=["python", "scripts/check_pr_perimeter.py", "{pr_number}", "--scan-thread"], blocking=True, + shadow_reason=PILOT_SHADOW_SANS_EMETTEUR, ), # Issue #14683 : garde substitution hr silencieuse. L'organe # `scripts/ci/check_hr_substitution.py` detecte les 4 notations CommonMark @@ -232,6 +270,7 @@ class Guard: argv=["python", "scripts/ci/check_hr_substitution.py", "{pr_number}"], blocking=True, warn_rc=(2,), + shadow_reason=PILOT_SHADOW_NATIF, ), # -- extension pilote (5 -> 9) ------------------------------------------ # Pattern 1 : execute une fois par chemin matchant (boucle bash d'origine @@ -251,6 +290,7 @@ class Guard: ], blocking=True, iterates_paths=True, + shadow_reason=PILOT_SHADOW_WORKFLOW_ENCORE_ACTIF, ), Guard( name="notebook-navlink-check", @@ -263,6 +303,7 @@ class Guard: argv=["python", "scripts/notebook_tools/check_notebook_navlinks.py", "--check"], blocking=True, + shadow_reason=PILOT_SHADOW_WORKFLOW_ENCORE_ACTIF, ), Guard( name="notebook-nav-chain-guard", @@ -276,6 +317,7 @@ class Guard: argv=["python", "scripts/notebook_tools/check_notebook_nav_chain.py", "--check"], blocking=True, + shadow_reason=PILOT_SHADOW_WORKFLOW_ENCORE_ACTIF, ), # F2 #18970 -- garde delta-only sur les violations STALE_LINK / # BROKEN / DEAD_RENDER. L'audit BRUT de `regen_quarto_render.py @@ -315,6 +357,7 @@ class Guard: ], blocking=True, needs_base=True, + shadow_reason=PILOT_SHADOW_WORKFLOW_ENCORE_ACTIF, ), Guard( name="notebook-interp-positioning-guard", @@ -328,6 +371,7 @@ class Guard: "--check", "--baseline", "scripts/notebook_tools/interp_positioning_baseline.json"], blocking=True, + shadow_reason=PILOT_SHADOW_WORKFLOW_ENCORE_ACTIF, ), Guard( name="markdown-rendering-guard", @@ -345,6 +389,7 @@ class Guard: "--baseline", "scripts/notebook_tools/markdown_rendering_baseline.json"], blocking=True, + shadow_reason=PILOT_SHADOW_WORKFLOW_ENCORE_ACTIF, ), Guard( name="self-hosted-runner-policy", @@ -359,6 +404,7 @@ class Guard: argv=["python", "scripts/ci/check_self_hosted_runner_policy.py", "--check"], blocking=True, + shadow_reason=PILOT_SHADOW_SANS_EMETTEUR, ), # -- extension c.1339 (10 -> 11) ---------------------------------------- # Ferme un angle mort du merge-gate mesure le 2026-08-24 (#12753) : aucun @@ -389,6 +435,7 @@ class Guard: "--base", "{base_ref}", "--head", "HEAD"], blocking=True, needs_base=True, + shadow_reason=PILOT_SHADOW_NATIF, ), # Defaut 3 de #15489 : "aucun garde dedie n'impose la casse canonique @@ -425,6 +472,7 @@ class Guard: "--base", "{base_ref}", "--head", "HEAD"], blocking=True, needs_base=True, + shadow_reason=PILOT_SHADOW_NATIF, ), # Cliquet #17784, phase ADVISORY : le meme organe liste en advisory les # notebooks AJOUTES sans suffixe de noyau (grammaire #16231 : le suffixe @@ -471,6 +519,7 @@ class Guard: "--base", "{base_ref}", "--head", "HEAD", "--offline"], blocking=True, needs_base=True, + shadow_reason=PILOT_SHADOW_NATIF, ), ] @@ -1528,6 +1577,13 @@ class Guard: "--all", "--check", ], blocking=True, + # `absorbed=True` (#19168) : sans lui le garde emettait + # `fast-lane (ombre): lake-direct-invocation-guard` avec une + # conclusion neutre et n'entrait pas dans `blocking_failed` -- son + # `blocking=True` ne bloquait rien. Verifie vert sur `main` avant + # absorption (`--all --check` -> rc=0, 6 fichiers en dette tous + # allowlistes), donc l'absorption ne rougit aucune PR existante. + absorbed=True, ), ] @@ -1572,6 +1628,13 @@ class Guard: "--diff", "{base_ref}...HEAD", ], blocking=True, + # `absorbed=True` (#19168) : mesure de l'issue sur la tete + # `34e045d3` de #19098 -- le check-run sortait sous + # `fast-lane (ombre): control-chars-in-cells-guard`, conclusion + # neutre, hors `blocking_failed`. Verifie vert sur `main` avant + # absorption (`--diff origin/main...HEAD` -> rc=0), donc l'absorption + # ne rougit aucune PR par dette heritee. + absorbed=True, needs_base=True, warn_rc=(2,), ), diff --git a/scripts/tests/test_fast_lane.py b/scripts/tests/test_fast_lane.py index 21f5a90af7..7248118044 100644 --- a/scripts/tests/test_fast_lane.py +++ b/scripts/tests/test_fast_lane.py @@ -270,6 +270,65 @@ def test_native_guards_are_declared_with_the_exact_sentinel(): ) +def test_aucun_garde_bloquant_n_est_inert_sans_declaration(): + """Un garde `blocking=True` non absorbe ne bloque RIEN (#19168). + + Le job « Always-on guards » lance `fast_lane.py --shadow`, et le moteur + calcule `effective_shadow = args.shadow and not guard.absorbed` : un garde + non absorbe emet sous `fast-lane (ombre): ` avec une conclusion NEUTRE et + n'entre pas dans `blocking_failed`. Son `blocking=True` est alors une + declaration sans effet, invisible partout -- le registre dit « bloquant », + le rollup dit « ombre ». + + Deux etats sont legitimes, et deux seulement : absorbe (il bloque + vraiment), ou volontairement en ombre avec un motif ET un critere de + bascule ecrits dans `shadow_reason`. Tout le reste est la classe que cette + issue ferme. + + Mesure fondatrice : sur la tete `34e045d3` de #19098, le check-run sortait + sous `fast-lane (ombre): control-chars-in-cells-guard` alors que TRANCHE16 + le declare bloquant. + """ + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + import fast_lane_registry + + guards = {} + for value in vars(fast_lane_registry).values(): + if isinstance(value, list) and value and all( + isinstance(item, Guard) for item in value): + for guard in value: + guards[guard.name] = guard + assert len(guards) >= 30, f"registre lu partiellement : {len(guards)}" + + inertes, contradictoires, declares, absorbes = [], [], [], [] + for name, guard in sorted(guards.items()): + if not guard.blocking: + continue + if guard.absorbed: + absorbes.append(name) + if guard.shadow_reason: + contradictoires.append(name) + elif guard.shadow_reason: + declares.append(name) + else: + inertes.append(name) + + assert inertes == [], ( + "gardes bloquants ni absorbes ni declares en ombre : leur " + "`blocking=True` ne bloque rien (le moteur les emet sous " + "`fast-lane (ombre): ` avec une conclusion neutre). Les absorber, ou " + "leur ecrire un `shadow_reason` avec son critere de bascule : " + f"{inertes}") + assert contradictoires == [], ( + "gardes absorbes qui portent encore un motif d'ombre : la declaration " + "est perimee, l'absorbe rend le motif faux : " + f"{contradictoires}") + # Controle positif : sans lui, vider le registre rendrait ce test vrai en + # silence -- il ne mesurerait plus rien. + assert absorbes, "aucun garde absorbe : le controle serait aveugle" + assert declares, "aucune ombre declaree : le controle serait aveugle" + + def test_delta_guards_declare_what_they_swap(): """Sans `swap_paths`, la phase 2 basculerait un ensemble vide et le delta comparerait HEAD a lui-meme -- un vert silencieux.""" @@ -656,9 +715,18 @@ def _drive_mixed_emission(monkeypatch, pilot_rc, tranche_rc): blocking=True, paths=["**/*.ipynb"], absorbed=True) monkeypatch.setattr(fl, "PILOT", [pilot]) monkeypatch.setattr(fl, "TRANCHE1", [tranche]) - monkeypatch.setattr(fl, "TRANCHE2", []) - monkeypatch.setattr(fl, "TRANCHE4", []) - monkeypatch.setattr(fl, "TRANCHE5", []) + # Toutes les AUTRES tranches doivent etre videes : n'en vider que quelques-unes + # laissait tourner les gardes REELLES des tranches oubliees. Leur rc etait + # injecte par le faux `run_argv` ci-dessous (`pilot_rc` pour tout argv autre + # que `cmd-tranche`), donc absorber une garde de tranche bloquante rendait + # `rc=1` et faisait rougir le job : le test de contraste du pilote mesurait + # alors le registre au lieu de la lane a deux gardes. Mesure du 2026-10-05 : + # l'absorption des tranches 15/16 (#19168) et 17 (#19118) a fait echouer ce + # test, qui passait par accident tant qu'aucune tranche oubliee n'etait + # absorbe et bloquante. + for _tranche in sorted(n for n in vars(fl) if n.startswith("TRANCHE")): + if _tranche != "TRANCHE1": + monkeypatch.setattr(fl, _tranche, []) monkeypatch.setattr(fl, "changed_files", lambda _ref: ["x.ipynb"]) monkeypatch.setattr( fl, "run_argv",