Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions scripts/ci/fast_lane_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 :
#
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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
Expand All @@ -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
Expand All @@ -251,6 +290,7 @@ class Guard:
],
blocking=True,
iterates_paths=True,
shadow_reason=PILOT_SHADOW_WORKFLOW_ENCORE_ACTIF,
),
Guard(
name="notebook-navlink-check",
Expand All @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -315,6 +357,7 @@ class Guard:
],
blocking=True,
needs_base=True,
shadow_reason=PILOT_SHADOW_WORKFLOW_ENCORE_ACTIF,
),
Guard(
name="notebook-interp-positioning-guard",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -471,6 +519,7 @@ class Guard:
"--base", "{base_ref}", "--head", "HEAD", "--offline"],
blocking=True,
needs_base=True,
shadow_reason=PILOT_SHADOW_NATIF,
),
]

Expand Down Expand Up @@ -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,
),
]

Expand Down Expand Up @@ -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,),
),
Expand Down
74 changes: 71 additions & 3 deletions scripts/tests/test_fast_lane.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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",
Expand Down
Loading