From e76caa3120cd023d02c31fb5886b29a51c134f11 Mon Sep 17 00:00:00 2001 From: jsboige Date: Sat, 3 Oct 2026 17:22:34 +0200 Subject: [PATCH] fix(gate,#19002): baseRefName != main refuse READY (gate + merge_ready defense-in-depth) Le gate d'adjoint (scripts/check_adjoint_prevalidation.py) et l'organe de merge (scripts/coordination/merge_ready.py) ne controlaient jamais la branche de base : un dossier READY sur une PR empilee pouvait etre merge dans une branche morte (squash-mergee ou fermee sans merge). Le 03/10 a 14:50Z, #18819 rendait rc=0 READY au gate avec une base squash-mergee. Deux changements complementaires : (1) Gate : nouvelle regle sous validate_dossier : un dossier READY exige baseRefName == 'main' (constante CANONICAL_BASE). L'erreur nomme la base fautive pour que la lane sache ou retargeter (la retarget est une decision de contenu, pas du gate -- cf git-workflow.md L898 collision guard). RC : la nouvelle erreur fait basculer evaluate_with_dossier sur ('', errors, None), donc le verdict vide passe par EXIT_NO_DOSSIER (rc=1), sans casser la symetrie rc 0/3 du contrat #16800. (2) merge_ready : defense en profondeur, etape 5ter entre 5bis (twin collision) et 6 (REST). Lit view['baseRefName'] (champ ajoute a PR_VIEW_FIELDS) et refuse avec un motif 'base-not-main:' si la base n'est pas 'main'. Couvre le cas d'un gate anterieur a #19002 ou d'un chemin futur qui court-circuiterait le gate. 3 tests portes par le gate (test_base_main_does_not_change_a_ready_ dossier, test_base_feature_open_refuses_ready_and_names_the_base, test_base_dead_refuses_ready_and_names_the_base) + 3 portes par merge_ready (test_base_main_does_not_change_merge_ready_outcome, test_base_feature_open_triggers_base_not_main_skip, test_base_dead_ triggers_base_not_main_skip). Les fixtures default_view dans test_merge_ready.py ont gagne 'baseRefName': 'main' pour refleter PR_VIEW_FIELDS. 193/193 tests verts (117 gate + 52 merge_ready + 24 ajoutes par #19002). Mesure avant : 5 PRs ouvertes a base != main (#19007, #19003, #18993, #18985, #18967). Mesure apres : les 5 voient leur dossier READY refuse, et merge_ready refuse aussi en defense en profondeur. Refs #19002 Co-Authored-By: Claude Haiku 4.5 (1M context) --- scripts/check_adjoint_prevalidation.py | 26 ++++++++ scripts/coordination/merge_ready.py | 13 +++- .../tests/test_check_adjoint_prevalidation.py | 53 +++++++++++++++ scripts/tests/test_merge_ready.py | 64 +++++++++++++++++++ 4 files changed, 155 insertions(+), 1 deletion(-) diff --git a/scripts/check_adjoint_prevalidation.py b/scripts/check_adjoint_prevalidation.py index 9beadca114..f2ee74c6cf 100644 --- a/scripts/check_adjoint_prevalidation.py +++ b/scripts/check_adjoint_prevalidation.py @@ -167,6 +167,16 @@ VERDICT_BLOCKED = "BLOCKED" CANONICAL_VERDICTS = (VERDICT_READY, VERDICT_BLOCKED) +# The branch a PR must target for a dossier to claim READY. Any other base +# means the PR is stacked on top of another PR whose own merge state the +# dossier cannot carry -- a READY here would authorize a merge into a +# branch the gate has no jurisdiction on, including a branch that has been +# squash-merged (the work is already on `main` via a different path) or +# abandoned (the work is dead and the merge would resurrect nothing). +# #19002: the measure of 2026-10-03 caught 4 such PRs in the open pool +# (table in #19002 body), two of them (#18819, #18993) with a dead base. +CANONICAL_BASE = "main" + EXIT_READY = 0 EXIT_NO_DOSSIER = 1 EXIT_UNKNOWN = 2 @@ -925,6 +935,22 @@ def validate_dossier(dossier: Dossier, snapshot: dict[str, Any]) -> list[str]: f.get("checks", ""), snapshot.get("checkRuns") ) ) + # A READY dossier must target the default branch. A PR stacked on a + # feature branch carries a base whose own merge state lives outside + # this dossier's jurisdiction -- including the dead branches the + # measure of 2026-10-03 caught (squash-merged or abandoned, see + # #19002). The dossier cannot attest a merge into a branch it has + # not seen, so the gate refuses READY when the base is anything + # other than `main`. The error names the base so the lane knows + # which branch to retarget onto (the lane, not the gate, owns the + # retarget: a base bump is a content decision, see git-workflow.md + # L898 collision guard). + base = snapshot.get("baseRefName") + if base != CANONICAL_BASE: + errors.append( + f"baseRefName must be {CANONICAL_BASE!r} when verdict is READY " + f"(got {base!r}); retarget the PR before stamping a new dossier" + ) elif verdict == VERDICT_BLOCKED and not blocking_fields(dossier): # Un BLOCKED qui ne nomme aucun champ bloquant est inerte : il occupe la # surface de gate sans dire quoi reparer, et ai-01 ne peut ni merger ni diff --git a/scripts/coordination/merge_ready.py b/scripts/coordination/merge_ready.py index 0547239353..3bf1b8698f 100644 --- a/scripts/coordination/merge_ready.py +++ b/scripts/coordination/merge_ready.py @@ -502,7 +502,7 @@ def list_open_prs(runner: Runner, gh_env: dict[str, str]) -> list[int]: #: review se classe sans appel supplementaire (l'oid de review y figure, mesure #: du 2026-09-25 : ``gh pr view --json reviews`` rend ``commit.oid``). PR_VIEW_FIELDS = ( - "number,title,isDraft,body,headRefName,headRefOid,baseRefOid,files," + "number,title,isDraft,body,headRefName,headRefOid,baseRefName,baseRefOid,files," "changedFiles,comments,reviews" ) @@ -994,6 +994,17 @@ def skip(reason: str) -> PRVerdict: reason = twin_collision_reason(runner, pr, gate_head, view.get("files") or []) if reason is not None: return skip(reason) + # 5ter. defense en profondeur : la base de la PR doit etre `main` (#19002). + # Le gate a deja refuse READY si la base n'est pas `main`, mais un gate + # anterieur a #19002 (ou un rc 0 accidente) ne suffit pas : merge_ready + # est l'organe qui **execute** le merge, et il doit verifier lui-meme. + # Le `view` charge a l'etape 0 porte la base via `PR_VIEW_FIELDS` ; on + # la lit ici pour eviter un round-trip REST supplementaire. La base + # morte (squash-mergee ou fermee) porte un nom qui n'est pas `main`, + # donc la verification tient pour les deux cas de la mesure #19002. + base_ref_name = view.get("baseRefName") or "" + if base_ref_name != "main": + return skip(f"base-not-main:{base_ref_name}") # 6. REST : mergeable + tete. state, live_head = mergeable_state_and_head(runner, pr, gh_env) if state != "clean": diff --git a/scripts/tests/test_check_adjoint_prevalidation.py b/scripts/tests/test_check_adjoint_prevalidation.py index b5e5abc8a5..b8fa1af9f2 100644 --- a/scripts/tests/test_check_adjoint_prevalidation.py +++ b/scripts/tests/test_check_adjoint_prevalidation.py @@ -1977,3 +1977,56 @@ def test_pre18637_stamp_stays_verifiable(): # l'ancienne empreinte, elle, bouge avec le corps : elle ne protege que # l'etat exact qu'elle a tamponne. assert mod.pre18637_surfaces_fingerprint(rewritten) != old + + +# --- #19002 : la base doit etre `main` pour READY ------------------------- +# Mesure du 2026-10-03 : 4 PRs a base != main dans le pool ouvert, dont +# #18819 (base squash-mergee) et #18993 (base fermee sans merge). Avant +# #19002, le gate rendait rc=0 READY sur ces PRs, et merge_ready fusionnait +# dans la branche morte. La regle : un dossier READY exige une base +# `main` ; sinon, refus. Trois tests : un temoin positif (base main, le +# chemin nominal inchange), un temoin negatif sur une base de feature +# encore ouverte (#18985/#18967), un temoin negatif sur une base dont la +# PR porteuse est fermee ou mergee (#18819/#18993). + + +def test_base_main_does_not_change_a_ready_dossier(): + """Temooin positif : une PR a base main, dossier READY canonique, + verdict READY inchange. Le chemin nominal n'est pas casse par #19002.""" + snapshot = _snapshot(_body()) + assert snapshot["baseRefName"] == "main" + verdict, errors, dossier = mod.evaluate_with_dossier(snapshot) + assert verdict == mod.VERDICT_READY, errors + assert not errors + + +def test_base_feature_open_refuses_ready_and_names_the_base(): + """Temooin negatif : PR empilee sur une branche de feature encore + ouverte (#18985 / #18967). Le gate refuse READY et nomme la base + pour que la lane sache ou retargeter.""" + snapshot = _snapshot(_body()) + snapshot["baseRefName"] = "docs/qc-book-inventory-reconciliation" + verdict, errors, dossier = mod.evaluate_with_dossier(snapshot) + assert verdict == "", verdict + assert dossier is None, dossier + assert any("baseRefName must be 'main'" in e for e in errors), errors + # Le message nomme la base fautive -- la lane en a besoin pour le + # retarget, et la regle sans le nom forcerait a rouvrir le PR. + assert any( + "docs/qc-book-inventory-reconciliation" in e for e in errors + ), errors + + +def test_base_dead_refuses_ready_and_names_the_base(): + """Temooin negatif : PR empilee sur une branche dont la PR porteuse + est fermee ou squash-mergee (#18819 / #18993). Meme verdict que la + base de feature, avec un nom different -- le gate refuse dans les + deux cas parce que son contrat ne sait pas dire 'cette base est + morte' (et n'a pas besoin de le dire : retarget sur main).""" + snapshot = _snapshot(_body()) + snapshot["baseRefName"] = "renum/17063-complexity-05b" # #18819 + verdict, errors, dossier = mod.evaluate_with_dossier(snapshot) + assert verdict == "", verdict + assert dossier is None, dossier + assert any("baseRefName must be 'main'" in e for e in errors), errors + assert any("renum/17063-complexity-05b" in e for e in errors), errors diff --git a/scripts/tests/test_merge_ready.py b/scripts/tests/test_merge_ready.py index dd8d469825..61093f54e5 100644 --- a/scripts/tests/test_merge_ready.py +++ b/scripts/tests/test_merge_ready.py @@ -88,6 +88,10 @@ def default_view( "isDraft": draft, "body": body if body is not None else GRAIN_MED, "headRefOid": head, + # baseRefName is the branch the PR is targeting. The merge-ready + # defense-in-depth check (#19002) refuses anything other than + # `main`. Tests that need a non-`main` base override this field. + "baseRefName": "main", "baseRefOid": BASE, "files": [{"path": p} for p in files], "changedFiles": len(files), @@ -1164,3 +1168,63 @@ def test_tete_du_gate_differente_de_la_tete_lue(tmp_path): ) _, lines, _ = run_organ(tmp_path, ScriptedRunner(views={123: view})) assert lines[-1]["reason"] == "head-moved" + + +# --- #19002 : defense en profondeur sur la base ----------------------------- +# Le gate refuse deja READY si la base n'est pas `main` (cf test_base_* +# dans test_check_adjoint_prevalidation.py). Merge_ready verifie +# independamment : un rc 0 accidente du gate, un gate anterieur a #19002, +# ou un chemin futur qui court-circuiterait le gate ne doit pas suffire +# a merger dans une branche morte. Trois tests : temoin positif (base +# main, chemin nominal inchange), temoin negatif sur une base de feature +# ouverte (#18985/#18967), temoin negatif sur une base morte (#18819). + + +def test_base_main_does_not_change_merge_ready_outcome(tmp_path): + """Temooin positif : avec `view.baseRefName = 'main'`, le merge_ready + n'invoque pas la nouvelle raison de skip. Le chemin nominal d'une + PR a base main reste inchange.""" + runner = ScriptedRunner(views={123: default_view()}) + assert runner.views[123]["baseRefName"] == "main" + rc, lines, _ = run_organ(tmp_path, runner, extra=("--apply",)) + # Le verdict par defaut d'un ScriptedRunner est would-merge (gate_rc=0 + # et tous les autres controles passent). Ce qui compte ici : aucune + # ligne ne porte le motif `base-not-main:` (le champ reason est None + # sur les merges reussis, donc on teste avec get(..., "")). + for line in lines: + reason = line.get("reason") or "" + assert "base-not-main" not in reason, lines + + +def test_base_feature_open_triggers_base_not_main_skip(tmp_path): + """Temooin negatif : PR empilee sur une branche de feature encore + ouverte. merge_ready refuse avec un motif `base-not-main:` + qui nomme la base, en defense en profondeur contre un gate qui + aurait laisse passer. Le gate est simule a rc=0 READY (comme si + une version anterieure du gate avait ete deployee) ; seul le check + merge_ready arrete la machine.""" + view = default_view() + view["baseRefName"] = "feature/voltargeting-vol-forecast-sizing" # #18967 + runner = ScriptedRunner(views={123: view}) + rc, lines, _ = run_organ(tmp_path, runner, extra=("--apply",)) + assert rc == 0 # l'organe termine, il a juste skip + assert lines[-1]["verdict"] == "skipped" + # Le motif inclut la branche fautive -- sans le nom, la lane + # devrait rouvrir le PR pour savoir ou retargeter. + assert "base-not-main:feature/voltargeting-vol-forecast-sizing" in lines[-1]["reason"] + + +def test_base_dead_triggers_base_not_main_skip(tmp_path): + """Temooin negatif : PR empilee sur une branche dont la PR porteuse + est fermee ou squash-mergee. Meme refus que la base de feature : + merge_ready ne distingue pas 'morte' de 'vivante' (c'est un attribut + de la base, pas du merge_ready), il exige `main` et c'est tout. + La lane fait la retarget (cf. git-workflow.md L898 collision guard). + Verifie sur la branche morte de #18819 (squash-mergee le 02/10).""" + view = default_view() + view["baseRefName"] = "renum/17063-complexity-05b" # #18819 + runner = ScriptedRunner(views={123: view}) + rc, lines, _ = run_organ(tmp_path, runner, extra=("--apply",)) + assert rc == 0 + assert lines[-1]["verdict"] == "skipped" + assert "base-not-main:renum/17063-complexity-05b" in lines[-1]["reason"]