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
26 changes: 26 additions & 0 deletions scripts/check_adjoint_prevalidation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion scripts/coordination/merge_ready.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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":
Expand Down
53 changes: 53 additions & 0 deletions scripts/tests/test_check_adjoint_prevalidation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
64 changes: 64 additions & 0 deletions scripts/tests/test_merge_ready.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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:<branche>`
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"]
Loading