From abf83e8a081d13cf83ac9c22b6bdc904d3bc201e Mon Sep 17 00:00:00 2001 From: jsboige Date: Sat, 10 Oct 2026 14:31:07 +0200 Subject: [PATCH 1/5] =?UTF-8?q?Fix(#20251):=20porte=20=C2=AB=20pile=20?= =?UTF-8?q?=C2=BB=20--=20le=20gate=20ne=20voyait=20pas=20les=20sommets=20d?= =?UTF-8?q?e=20pile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'organe juge UNE PR contre `main` a sa tete exacte, et ne voyait pas qu'une autre PR ouverte porte une version plus recente des memes commits. Instance fondatrice du 2026-10-10 : la pile Percolation #19548 -> #19556 -> #19567, les trois de base `main`. #19567 avait ete rebatie avant les corrections poussees ensuite sur #19548 et #19556 ; le gate a rendu READY, #19567 a ete mergee en premier, et main a recu les versions anterieures. La porte vit dans derive_verdict(), le point de derivation unique consomme par --derive-verdict, --queue et refute_ready_verdict : les deux modes demandes sont couverts par construction. Les commits s'apparient par SUJET (un rebase reecrit les SHA, et un sommet de pile est une branche rebatiee) ; la porte se tait quand la tete visee est un ancetre de l'autre, pour laisser passer le BAS d'une pile lineaire ; les sujets de re-declenchement sont ignores ; une mesure impossible prive l'organe de la porte sans fabriquer de refus. `main` est rafraichi dans le meme `git fetch` que les tetes, donc la mesure ne depend pas de la fraicheur du clone de l'appelant. Controle positif rejoue sur l'etat reel : 128 PR ouvertes, 12 sommets identifies, dont #19548/#19556 avec les deux sujets mesures firsthand. Le critere 3 de l'issue demandait l'etat avant le merge de #19567, qui est MERGED : le controle est rejoue sur la seule paire encore reproductible, et la PR le dit. Co-Authored-By: Claude Haiku 4.5 (1M context) --- scripts/check_adjoint_prevalidation.py | 293 +++++++++++++++++- .../tests/test_check_adjoint_prevalidation.py | 211 +++++++++++++ 2 files changed, 501 insertions(+), 3 deletions(-) diff --git a/scripts/check_adjoint_prevalidation.py b/scripts/check_adjoint_prevalidation.py index 83848da7cd..c31f57a67d 100644 --- a/scripts/check_adjoint_prevalidation.py +++ b/scripts/check_adjoint_prevalidation.py @@ -57,6 +57,18 @@ dispense with reading the surfaces: the organ only sees its markers, and who lifted a remark, when, and on what substance are read by hand (CLAUDE.md §B.0). +The same derivation carries a STACK door (#20251), and it closes the third +blind spot: the gate judges ONE pull request against `main` and could not see +that another OPEN pull request carries a newer version of the same commits. A +stack top came out READY, was merged first, and `main` received the version +that predated the corrections its own base branch had received hours earlier. +The door compares commit SUBJECTS (a rebase rewrites SHAs and leaves subjects +alone -- a stack top is precisely a rebased branch), skips pairs where this +head is an ancestor of the other's (a linear stack bottom merges first +correctly, so the door stays silent), and ignores re-trigger subjects. A +measurement it cannot complete removes the door and says so on stderr; it +never fabricates a refusal. `--stack-index` prints what the door reads. + Exit codes -- dossier INTEGRITY and PR MERGEABILITY are two questions, and conflating them is what this gate used to do (#16800): @@ -311,7 +323,7 @@ class Dossier: API_USAGE: dict[str, int] = {"rest": 0, "graphql": 0} -def gh_json(args: list[str]) -> Any: +def _gh_invoke(args: list[str]) -> str: proc = subprocess.run( ["gh", *args], capture_output=True, text=True, encoding="utf-8" ) @@ -325,7 +337,21 @@ def gh_json(args: list[str]) -> Any: API_USAGE["rest"] += 1 if proc.returncode != 0: raise RuntimeError(proc.stderr.strip() or "gh command failed") - return json.loads(proc.stdout) + return proc.stdout + + +def gh_json(args: list[str]) -> Any: + return json.loads(_gh_invoke(args)) + + +def gh_lines(args: list[str]) -> list[str]: + """Lignes non vides d'une sortie ``gh`` brute. + + Existe pour les appels pagines a ``--jq`` scalaire (``.[].number``) : + ``--paginate`` concatene un document par page et ``--slurp`` est refuse + avec ``--jq``, donc la sortie n'est pas un JSON unique. + """ + return [line for line in _gh_invoke(args).splitlines() if line.strip()] def parse_dossier( @@ -917,8 +943,236 @@ def refute_ready_b0( return verdict, [], dossier +# --------------------------------------------------------------------------- +# Porte « pile » (#20251) +# +# L'organe juge UNE PR contre `main`, a sa tete exacte. Il ne voyait pas qu'une +# AUTRE PR ouverte porte une version plus recente des memes fichiers : un sommet +# de pile pouvait donc sortir READY alors que les PRs du bas portaient des +# corrections qu'il n'avait pas. +# +# Instance fondatrice (2026-10-10) : la pile Percolation #19548 -> #19556 -> +# #19567, les trois de base `main`. #19567 avait ete rebatie AVANT les +# corrections poussees ensuite sur #19548 et #19556 (enonce en `Phi_iso(n)`, +# instrument R2 et verdict INCONCLUSIVE, re-execution C.2, reponse a un +# REQUEST_CHANGES). Le gate a rendu READY, #19567 a ete mergee en premier, et +# `main` a recu les versions anterieures aux corrections. +# +# Les commits sont apparies par SUJET, jamais par SHA : un rebase change les +# SHA sans changer ce qu'une PR apporte -- et un sommet de pile est justement +# une PR rebatie. +# --------------------------------------------------------------------------- + +STACK_REF_PREFIX = "refs/remotes/prh/" +STACK_FETCH_TRIES = 6 +# Sujets de re-declenchement : ils ne portent aucun contenu, et ne doivent pas, +# a eux seuls, faire passer une PR pour un sommet de pile. +STACK_NOISE_SUBJECTS = ("empty commit", "regenerer", "render-list") + + +@dataclass +class StackEntry: + """Ce qu'une PR ouverte apporte par rapport a `main`.""" + + number: int + ref: str + commits: list[str] + # sha -> sujet. Le sujet est ce qui s'apparie entre deux versions d'une PR + # rebatie ; le sha, lui, ne survit pas au rebase. + subjects: dict[str, str] + + +def _git(args: list[str], check: bool = True) -> subprocess.CompletedProcess: + proc = subprocess.run( + ["git", *args], capture_output=True, text=True, encoding="utf-8", + errors="replace", + ) + if check and proc.returncode != 0: + raise RuntimeError(f"git {' '.join(args[:3])} -> {proc.stderr.strip()[:200]}") + return proc + + +def _own_ref_content(ref: str) -> tuple[list[str], dict[str, str]]: + """``(commits, sha -> sujet)`` propres a ``ref`` : hors `main`, sans fusion. + + Un seul appel git par ref : les SHA et les sujets viennent du meme + ``log``. Deux appels par PR doubleraient le cout d'une passe qui touche + toutes les PR ouvertes. + """ + proc = _git( + ["log", "--no-merges", "--format=%H\t%s", ref, "--not", "origin/main"], + check=False, + ) + if proc.returncode != 0: + return [], {} + commits: list[str] = [] + subjects: dict[str, str] = {} + for line in proc.stdout.splitlines(): + sha, _, subject = line.partition("\t") + sha = sha.strip() + if sha: + commits.append(sha) + subjects[sha] = subject.strip() + return commits, subjects + + +def _fetch_pr_heads(numbers: list[int]) -> tuple[list[int], list[str]]: + """Recupere les tetes des PR ouvertes en refs locales, plus `main`. + + Un lot dont une seule ref a disparu (branche de fork supprimee) echoue + entierement : on retire la ref fautive et on relance, jusqu'a + ``STACK_FETCH_TRIES``. Une ref de tete disparue n'est pas une candidate de + pile, elle est simplement ecartee -- ce n'est pas un incident. + + `main` voyage dans le MEME lot, et c'est deliberé : ``own_commits`` se + mesure ``--not origin/main``, donc un clone en retard ferait apparaitre + comme « propres » des commits deja merges -- la porte inventerait des piles. + Une passe qui rafraichit sa reference ne depend pas de la fraicheur de + l'appelant. + """ + remaining = list(numbers) + for _ in range(STACK_FETCH_TRIES): + specs = ["main:refs/remotes/origin/main"] + specs += [f"pull/{p}/head:{STACK_REF_PREFIX}{p}" for p in remaining] + proc = _git(["fetch", "-q", "origin", *specs], check=False) + if proc.returncode == 0: + return remaining, [] + gone = set(re.findall(r"remote ref pull/(\d+)/head", proc.stderr or "")) + if not gone: + detail = (proc.stderr or "").strip()[:200] or f"rc={proc.returncode}" + return [], [f"recuperation des tetes de PR impossible : {detail}"] + remaining = [p for p in remaining if str(p) not in gone] + return [], [ + "recuperation des tetes de PR : tentatives epuisees " + "(des refs disparaissent a chaque passe)" + ] + + +def build_stack_index() -> tuple[dict[int, StackEntry] | None, list[str]]: + """Une passe : ce que chaque PR ouverte apporte par rapport a `main`. + + Renvoie ``(None, incidents)`` quand la mesure n'est pas possible. Un + incident ne fabrique JAMAIS un refus : il prive seulement l'organe d'une + porte (cf la porte B.0, dont l'echec de mesure est fail-closed a l'inverse, + parce qu'elle infirme une affirmation -- ici on ne peut que constater). + """ + try: + numbers = [ + int(line) for line in gh_lines([ + "api", "--paginate", f"repos/{REPO}/pulls?state=open&per_page=100", + "--jq", ".[].number", + ]) + ] + except Exception as exc: # noqa: BLE001 -- mesure impossible, pas un crash + return None, [f"enumeration des PR ouvertes impossible : {exc}"] + fetched, incidents = _fetch_pr_heads(numbers) + if incidents: + return None, incidents + index: dict[int, StackEntry] = {} + for p in fetched: + ref = f"{STACK_REF_PREFIX}{p}" + commits, subjects = _own_ref_content(ref) + index[p] = StackEntry(p, ref, commits, subjects) + return index, [] + + +def _subjects_of(entry: StackEntry) -> set[str]: + return set(entry.subjects.values()) + + +def _is_stack_noise(subject: str) -> bool: + return subject.startswith("Merge ") or any( + token in subject for token in STACK_NOISE_SUBJECTS + ) + + +def _stack_partners(target: int, index: dict[int, StackEntry]) -> list[int]: + """PRs ouvertes dont ``target`` est un sommet de pile (cf ``stack_reasons``). + + Predicat unique, partage par la porte et par ``--stack-index`` : deux + enonces du meme critere divergeraient au premier amendement. + """ + entry = index.get(target) + if entry is None or not entry.commits: + return [] + mine = _subjects_of(entry) + partners: list[int] = [] + for number in sorted(index): + if number == target: + continue + other = index[number] + if not other.commits: + continue + if not set(entry.commits) & set(other.commits): + continue + # Bas de pile lineaire : la tete de `target` est un ancetre de celle + # de `other`, donc merger `target` d'abord est l'ordre correct. + if _git( + ["merge-base", "--is-ancestor", entry.ref, other.ref], check=False + ).returncode == 0: + continue + if not [ + s for s in (_subjects_of(other) - mine) if not _is_stack_noise(s) + ]: + continue + partners.append(number) + return partners + + +def stack_reasons(target: int, index: dict[int, StackEntry]) -> list[str]: + """Cause nommee quand ``target`` est un sommet de pile perime. + + ``target`` partage des commits avec une PR ouverte ``p`` qui en porte + d'autres, absents de ``target``, et sa tete n'est PAS un ancetre de celle + de ``p``. Deux cas : + + - **bas de pile lineaire** (tete de ``target`` ancetre de celle de ``p``) : + merger ``target`` d'abord est l'ordre correct -- aucune raison, et c'est + le controle negatif qui distingue la porte d'un simple « deux PRs se + ressemblent » ; + - **sommet de pile** : ``p`` porte des corrections que ``target`` n'a pas. + Merger ``target`` d'abord ferait atterrir sur `main` la version + anterieure. + """ + entry = index.get(target) + if entry is None: + return [] + mine = _subjects_of(entry) + reasons: list[str] = [] + for number in _stack_partners(target, index): + other = index[number] + shared = set(entry.commits) & set(other.commits) + missing = sorted( + s for s in (_subjects_of(other) - mine) if not _is_stack_noise(s) + ) + reasons.append( + f"stack top: shares {len(shared)} commit(s) with OPEN #{number}, which " + f"carries {len(missing)} commit(s) absent here -- merging this one first " + f"would land the earlier version on main (e.g. {missing[0][:90]!r})" + ) + return reasons + + +_STACK_INDEX_CACHE: dict[str, Any] = {"built": False, "index": None} +# Sentinelle : distingue « pas d'argument » (mesure live, mise en cache) de +# ``stack=None`` (desactivee explicitement, usage des tests). +_STACK_AUTO = object() + + +def default_stack_index() -> dict[int, StackEntry] | None: + if not _STACK_INDEX_CACHE["built"]: + index, incidents = build_stack_index() + _STACK_INDEX_CACHE.update(built=True, index=index) + for incident in incidents: + # Un incident se DIT et ne bloque pas : sortie stderr, verdict + # inchange. C'est la meme frontiere que l'organe #20250, ou une + # base illisible ne fabrique aucun finding. + print(f"STACK (WARN, mesure incomplete): {incident}", file=sys.stderr) + return _STACK_INDEX_CACHE["index"] + + def derive_verdict( - snapshot: dict[str, Any], probe: Any = None + snapshot: dict[str, Any], probe: Any = None, stack: Any = _STACK_AUTO ) -> tuple[str, list[str]]: """#18933 -- le verdict DERIVE de l'organe, pas ecrit par l'emetteur. @@ -961,6 +1215,11 @@ def derive_verdict( reasons.extend( b0_claim_contradictions("clear", (probe or probe_b0)(snapshot["number"])) ) + # Porte « pile » (#20251) : dernier ajout, pour ne pas deplacer l'ordre des + # raisons deja produites par les portes precedentes. + index = default_stack_index() if stack is _STACK_AUTO else stack + if index: + reasons.extend(stack_reasons(snapshot["number"], index)) if reasons: return VERDICT_BLOCKED, reasons return VERDICT_READY, [] @@ -2466,7 +2725,35 @@ def main() -> int: "(organ/organ-command/organ-rc). Only the reading acts " "(complete/body/scope/domain) stay for the emitting lane to fill.", ) + parser.add_argument( + "--stack-index", + action="store_true", + help="#20251: print the open-PR stack index the stack door reads " + "(per PR: own commits off main, and the PRs it is a stack top of). " + "Read-only, no PR argument; exits 2 when the measurement itself " + "fails, which is the same policy the door applies.", + ) args = parser.parse_args() + if args.stack_index: + index, incidents = build_stack_index() + if index is None: + for incident in incidents: + print(f"STACK (WARN, mesure incomplete): {incident}", + file=sys.stderr) + return EXIT_UNKNOWN + payload = { + "open_prs": len(index), + "entries": { + str(n): { + "own_commits": len(e.commits), + "stack_top_of": _stack_partners(n, index), + "reasons": stack_reasons(n, index), + } + for n, e in sorted(index.items()) + }, + } + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return EXIT_READY modes = sum(bool(m) for m in (args.pr, args.queue, args.consume)) if modes != 1: parser.error( diff --git a/scripts/tests/test_check_adjoint_prevalidation.py b/scripts/tests/test_check_adjoint_prevalidation.py index d5795823e0..46f7fabfff 100644 --- a/scripts/tests/test_check_adjoint_prevalidation.py +++ b/scripts/tests/test_check_adjoint_prevalidation.py @@ -2,6 +2,7 @@ import importlib.util import json +import subprocess import sys from pathlib import Path @@ -16,6 +17,10 @@ HEAD = "0123456789abcdef0123456789abcdef01234567" +# Le vrai acces a l'index de pile, capture AVANT que le garde d'hermeticite ne +# le remplace : le test du cache a besoin du code reel (#20251). +_REAL_DEFAULT_STACK_INDEX = mod.default_stack_index + def _comment(body: str, login: str = "jsboige") -> dict: return {"author": {"login": login}, "body": body} @@ -3452,3 +3457,209 @@ def test_grain_lane_re_refuse_le_point_final_de_phrase(): # Aucune lane ne se reduit a un point : machine et workspace requis. assert mod.GRAIN_LANE_RE.findall("Grain: MED/docs — lane :CoursIA-2") == [] assert mod.GRAIN_LANE_RE.findall("Grain: MED/docs — lane myia-po-2023:") == [] + + +# --- #20251 : porte « pile » ------------------------------------------------- +# L'organe juge UNE PR contre `main`. Il ne voyait pas qu'une AUTRE PR ouverte +# porte une version plus recente des memes commits : un sommet de pile sortait +# READY, merger en premier faisait atterrir sur `main` la version anterieure aux +# corrections. Instance fondatrice : la pile Percolation #19548 -> #19556 -> +# #19567 le 2026-10-10. +# +# Ces tests sont hermetiques : l'index est injecte, et `git` est simule. Le +# controle positif, lui, se rejoue sur le depot reel avec `--stack-index`. + + +@pytest.fixture(autouse=True) +def _no_live_stack_index(monkeypatch): + """Le harnais de tests ne touche jamais le reseau par la porte « pile ». + + Sans ce garde, toute fonction qui appelle ``derive_verdict`` sans argument + ``stack`` declencherait une enumeration des PR ouvertes et un `git fetch` + de chaque tete. Les tests qui ont besoin d'un index l'injectent. + """ + monkeypatch.setattr(mod, "default_stack_index", lambda: None) + + +def _synthetic_git(ancestor_pairs=()): + """`git` simule : seule la question d'ascendance a une reponse.""" + pairs = set(ancestor_pairs) + calls = [] + + def fake(args, check=True): + calls.append(list(args)) + if args[:2] == ["merge-base", "--is-ancestor"]: + rc = 0 if (args[2], args[3]) in pairs else 1 + else: + rc = 1 + return subprocess.CompletedProcess(["git", *args], rc, "", "") + + fake.calls = calls + return fake + + +def _stack_entry(pr, subjects): + return mod.StackEntry( + number=pr, + ref=f"{mod.STACK_REF_PREFIX}{pr}", + commits=list(subjects), + subjects=dict(subjects), + ) + + +def _stack_index(*entries): + return {e.number: e for e in entries} + + +def test_stack_top_divergent_is_not_ready(monkeypatch): + """#20251 (1) -- sommet de pile diverge : #19548 porte la version ancienne. + + #19548 et #19556 partagent des commits, aucune des deux tetes n'est + ancetre de l'autre, et #19556 porte un commit absent de #19548 : merger + #19548 d'abord ferait atterrir sur `main` la version anterieure. + """ + monkeypatch.setattr(mod, "_git", _synthetic_git()) + index = _stack_index( + _stack_entry(19548, {"a": "A", "b": "B", "c": "C"}), + _stack_entry(19556, {"a": "A", "b": "B", "c": "C", + "d": "D: correctif absent du bas de pile"}), + ) + snapshot = _base_snapshot() + snapshot["number"] = 19548 + verdict, reasons = mod.derive_verdict( + snapshot, lambda pr: {"blocked": False, "blocking": []}, stack=index + ) + assert verdict == mod.VERDICT_BLOCKED, reasons + assert any("stack top" in r and "#19556" in r for r in reasons), reasons + assert any("D: correctif absent du bas de pile" in r for r in reasons), reasons + + +def test_stack_bottom_linear_stays_ready(monkeypatch): + """#20251 (2) -- bas de pile lineaire : merger en premier est l'ordre juste. + + La tete de #19548 est un ancetre de celle de #19556 : la porte ne dit rien. + C'est le controle negatif qui distingue la porte d'un simple « deux PRs se + ressemblent ». + """ + bottom = mod.STACK_REF_PREFIX + "19548" + top = mod.STACK_REF_PREFIX + "19556" + monkeypatch.setattr(mod, "_git", _synthetic_git({(bottom, top)})) + index = _stack_index( + _stack_entry(19548, {"a": "A", "b": "B"}), + _stack_entry(19556, {"a": "A", "b": "B", "c": "C"}), + ) + snapshot = _base_snapshot() + snapshot["number"] = 19548 + verdict, reasons = mod.derive_verdict( + snapshot, lambda pr: {"blocked": False, "blocking": []}, stack=index + ) + assert verdict == mod.VERDICT_READY, reasons + assert reasons == [] + + +def test_stack_without_shared_commit_is_unchanged(monkeypatch): + """#20251 (3) -- aucune PR ne partage de commit : verdict inchange. + + Deux PRs ouvertes sur des chemins disjoints ne sont pas une pile, meme si + le tapis les sert voisines. + """ + monkeypatch.setattr(mod, "_git", _synthetic_git()) + index = _stack_index( + _stack_entry(19548, {"a": "A", "b": "B"}), + _stack_entry(19556, {"c": "C", "d": "D"}), + ) + verdict, reasons = mod.derive_verdict( + _base_snapshot(), lambda pr: {"blocked": False, "blocking": []}, stack=index + ) + assert verdict == mod.VERDICT_READY, reasons + assert reasons == [] + + +def test_stack_noise_subjects_alone_do_not_block(monkeypatch): + """#20251 (3bis) -- un commit de re-declenchement n'est pas un diff. + + Sans ce filtre, deux PRs qui ne different que par un vidage de commit + seraient vues comme une pile, et la porte refuserait du bruit. + """ + monkeypatch.setattr(mod, "_git", _synthetic_git()) + index = _stack_index( + _stack_entry(19548, {"a": "A", "b": "B"}), + _stack_entry(19556, {"a": "A", "b": "B", + "c": "Fix: empty commit to retrigger CI"}), + ) + verdict, reasons = mod.derive_verdict( + _base_snapshot(), lambda pr: {"blocked": False, "blocking": []}, stack=index + ) + assert verdict == mod.VERDICT_READY, reasons + + +def test_stack_vanished_head_ref_is_ignored(monkeypatch): + """#20251 (4) -- ref de tete disparue : ecartee, jamais un incident. + + Un lot de `git fetch` echoue entierement des qu'une ref a disparu : on + retire la fautive et on relance. La PR dont la branche n'existe plus n'est + pas une candidate de pile -- elle ne doit pas priver l'organe de la porte, + ni fabriquer un refus. + """ + state = {"tries": 0} + + def fake(args, check=True): + if args[0] == "fetch": + state["tries"] += 1 + if state["tries"] == 1: + return subprocess.CompletedProcess( + ["git", *args], 1, "", + "fatal: couldn't find remote ref pull/19548/head", + ) + return subprocess.CompletedProcess(["git", *args], 0, "", "") + raise AssertionError(f"appel git inattendu : {args}") + + monkeypatch.setattr(mod, "_git", fake) + remaining, incidents = mod._fetch_pr_heads([19548, 19556]) + assert remaining == [19556], remaining + assert incidents == [] + assert state["tries"] == 2 + + +def test_stack_unfetchable_refs_are_an_incident_not_a_refusal(monkeypatch): + """#20251 -- mesure impossible : aucun refus fabrique, un incident dit. + + L'echec de mesure prive l'organe d'une porte ; il ne l'autorise pas a + refuser. Toute la classe est couverte par ce cas generique (reseau, + authentification, depot illisible). + """ + def fake(args, check=True): + return subprocess.CompletedProcess(["git", *args], 1, "", "boom reseau") + + monkeypatch.setattr(mod, "_git", fake) + remaining, incidents = mod._fetch_pr_heads([19548]) + assert remaining == [] + assert incidents and "impossible" in incidents[0] + + def boom(args): + raise RuntimeError("gh indisponible") + + monkeypatch.setattr(mod, "gh_lines", boom) + index, incidents = mod.build_stack_index() + assert index is None + assert incidents + + +def test_stack_index_cache_is_built_once(monkeypatch): + """#20251 -- la passe est payee une fois par processus, pas par PR. + + `--queue` evalue N candidates : rejouer l'enumeration et le `git fetch` a + chaque appel ferait payer N fois une mesure identique. + """ + calls = {"n": 0} + + def fake_build(): + calls["n"] += 1 + return {1: _stack_entry(1, {"a": "A"})}, [] + + monkeypatch.setattr(mod, "build_stack_index", fake_build) + monkeypatch.setattr(mod, "default_stack_index", _REAL_DEFAULT_STACK_INDEX) + monkeypatch.setattr(mod, "_STACK_INDEX_CACHE", {"built": False, "index": None}) + first = mod.default_stack_index() + second = mod.default_stack_index() + assert first == second and calls["n"] == 1 From bcd1006324a2698190920b2bf1d081b7791e304f Mon Sep 17 00:00:00 2001 From: jsboige Date: Sat, 10 Oct 2026 14:33:57 +0200 Subject: [PATCH 2/5] =?UTF-8?q?Fix(#20251):=20la=20porte=20=C2=AB=20pile?= =?UTF-8?q?=20=C2=BB=20elague=20ses=20propres=20refs=20dans=20le=20clone?= =?UTF-8?q?=20appelant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La passe ecrit une ref par PR ouverte dans le clone de l'appelant, et rien ne les nettoyait jamais : une ref par PR jamais fermee s'y accumulerait pour toujours. Une ref hors de l'ensemble courant n'est pas une mesure, c'est un residu d'une passe precedente -- elle se supprime. Cout nul quand rien n'a ferme depuis la derniere passe. Verifie de bout en bout sur le clone reel : une ref `refs/remotes/prh/999999` posee a la main disparait apres une passe de `--stack-index`. 2 tests neufs (211/211 verts, suite toujours hermetique). Co-Authored-By: Claude Haiku 4.5 (1M context) --- scripts/check_adjoint_prevalidation.py | 19 ++++++++ .../tests/test_check_adjoint_prevalidation.py | 45 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/scripts/check_adjoint_prevalidation.py b/scripts/check_adjoint_prevalidation.py index c31f57a67d..09f9d24718 100644 --- a/scripts/check_adjoint_prevalidation.py +++ b/scripts/check_adjoint_prevalidation.py @@ -1048,6 +1048,24 @@ def _fetch_pr_heads(numbers: list[int]) -> tuple[list[int], list[str]]: ] +def _prune_stack_refs(keep: list[int]) -> None: + """Retire les refs de tetes qui ne correspondent plus a une PR ouverte. + + La passe ECRIT dans le clone de l'appelant (une ref par PR ouverte) : sans + elagage, elle y laisserait une ref par PR jamais nettoyee, pour toujours. + Une ref hors de l'ensemble courant n'est pas une mesure -- c'est un residu + d'une passe precedente, et elle se supprime. Le cout est nul quand rien n'a + ferme depuis la derniere passe. + """ + proc = _git(["for-each-ref", "--format=%(refname)", STACK_REF_PREFIX]) + if proc.returncode != 0: + return + keep_refs = {f"{STACK_REF_PREFIX}{n}" for n in keep} + for ref in proc.stdout.split(): + if ref and ref not in keep_refs: + _git(["update-ref", "-d", ref], check=False) + + def build_stack_index() -> tuple[dict[int, StackEntry] | None, list[str]]: """Une passe : ce que chaque PR ouverte apporte par rapport a `main`. @@ -1073,6 +1091,7 @@ def build_stack_index() -> tuple[dict[int, StackEntry] | None, list[str]]: ref = f"{STACK_REF_PREFIX}{p}" commits, subjects = _own_ref_content(ref) index[p] = StackEntry(p, ref, commits, subjects) + _prune_stack_refs(fetched) return index, [] diff --git a/scripts/tests/test_check_adjoint_prevalidation.py b/scripts/tests/test_check_adjoint_prevalidation.py index 46f7fabfff..d9d4db585d 100644 --- a/scripts/tests/test_check_adjoint_prevalidation.py +++ b/scripts/tests/test_check_adjoint_prevalidation.py @@ -3663,3 +3663,48 @@ def fake_build(): first = mod.default_stack_index() second = mod.default_stack_index() assert first == second and calls["n"] == 1 + + +def test_stack_refs_are_pruned_to_the_open_set(monkeypatch): + """#20251 -- la passe elague ses propres refs dans le clone de l'appelant. + + Elle en ecrit une par PR ouverte ; sans elagage il en resterait une par PR + jamais nettoyee. Une ref hors de l'ensemble courant est un residu d'une + passe precedente, pas une mesure. + """ + deleted: list[str] = [] + now = "refs/remotes/prh/19548 refs/remotes/prh/19556 refs/remotes/prh/99999" + + def fake(args, check=True): + if args[0] == "for-each-ref": + return subprocess.CompletedProcess(["git", *args], 0, now, "") + if args[0] == "update-ref" and args[1] == "-d": + deleted.append(args[2]) + return subprocess.CompletedProcess(["git", *args], 0, "", "") + raise AssertionError(f"appel git inattendu : {args}") + + monkeypatch.setattr(mod, "_git", fake) + mod._prune_stack_refs([19548, 19556]) + + assert deleted == ["refs/remotes/prh/99999"], deleted + + +def test_stack_prune_is_quiet_when_nothing_closed(monkeypatch): + """#20251 -- rien a elaguer = aucun appel `update-ref`. + + Le cas nominal de la passe : la totalite des refs correspond a une PR + ouverte, donc l'elagage ne coute rien. + """ + calls: list[list[str]] = [] + + def fake(args, check=True): + calls.append(list(args)) + if args[0] == "for-each-ref": + return subprocess.CompletedProcess( + ["git", *args], 0, "refs/remotes/prh/1 refs/remotes/prh/2", "") + raise AssertionError(f"appel git inattendu : {args}") + + monkeypatch.setattr(mod, "_git", fake) + mod._prune_stack_refs([1, 2]) + + assert [c[0] for c in calls] == ["for-each-ref"], calls From 5a10afacc7c6629f6393fd4c882bb67f9981bd84 Mon Sep 17 00:00:00 2001 From: jsboige Date: Sat, 10 Oct 2026 14:35:22 +0200 Subject: [PATCH 3/5] Test(#20251): la porte atteint le chemin du MERGE organe, pas seulement l'emission L'assertion d'integration qui compte : `merge_ready.run_gate` lance `check_adjoint_prevalidation.py --json`, dont le flot passe par `refute_ready_verdict` -> `derive_verdict`. La porte ne protege donc pas seulement la lane emettrice, elle atteint le chemin du merge -- ce qui est exactement l'enjeu, puisque c'est un merge de sommet de pile qui a fait atterrir la version anterieure sur main. Une porte qui n'aurait couvert que l'emission aurait laisse passer l'incident fondateur. Deux tests : la porte demote un dossier READY sur le chemin du merge, et son controle negatif (elle reste silencieuse quand il n'y a pas de pile), sans lequel le premier passerait aussi sur une porte qui refuse tout. 213/213 verts. Co-Authored-By: Claude Haiku 4.5 (1M context) --- scripts/check_adjoint_prevalidation.py | 4 ++ .../tests/test_check_adjoint_prevalidation.py | 50 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/scripts/check_adjoint_prevalidation.py b/scripts/check_adjoint_prevalidation.py index 09f9d24718..ef565ad05b 100644 --- a/scripts/check_adjoint_prevalidation.py +++ b/scripts/check_adjoint_prevalidation.py @@ -68,6 +68,10 @@ correctly, so the door stays silent), and ignores re-trigger subjects. A measurement it cannot complete removes the door and says so on stderr; it never fabricates a refusal. `--stack-index` prints what the door reads. +Because `derive_verdict` is also reached by `refute_ready_verdict`, the door +covers the path the merge organ takes (`merge_ready.run_gate` shells out to +this gate), not only the emitting lane's: what it prevents is a MERGE, and a +gate that only guarded emission would have let the founding incident through. Exit codes -- dossier INTEGRITY and PR MERGEABILITY are two questions, and conflating them is what this gate used to do (#16800): diff --git a/scripts/tests/test_check_adjoint_prevalidation.py b/scripts/tests/test_check_adjoint_prevalidation.py index d9d4db585d..20507714fc 100644 --- a/scripts/tests/test_check_adjoint_prevalidation.py +++ b/scripts/tests/test_check_adjoint_prevalidation.py @@ -3708,3 +3708,53 @@ def fake(args, check=True): mod._prune_stack_refs([1, 2]) assert [c[0] for c in calls] == ["for-each-ref"], calls + + +def test_stack_door_reaches_the_merge_gate_path(monkeypatch): + """#20251 -- la porte atteint le chemin que le merge organe emprunte. + + C'est l'assertion d'integration qui compte : `merge_ready.run_gate` lance + `check_adjoint_prevalidation.py --json`, dont le flot passe par + `refute_ready_verdict` -> `derive_verdict`. Sans ce maillon, la porte + n'aurait protege que la lane emettrice, pas le merge -- or c'est un MERGE + de sommet de pile qui a fait atterrir la version anterieure sur `main`. + """ + verdict, dossier = _ready_dossier() + snapshot = _snapshot(_body()) + monkeypatch.setattr(mod, "_git", _synthetic_git()) + monkeypatch.setattr(mod, "default_stack_index", lambda: _stack_index( + _stack_entry(snapshot["number"], {"a": "A", "b": "B"}), + _stack_entry(19556, {"a": "A", "b": "B", "c": "C: correctif ulterieur"}), + )) + + verdict, errors, dossier = mod.refute_ready_verdict( + snapshot, verdict, [], dossier, + probe=lambda pr: {"blocked": False, "blocking": []}, + ) + + assert verdict != mod.VERDICT_READY, (verdict, errors) + assert dossier is None + assert any("stack top" in e and "#19556" in e for e in errors), errors + + +def test_stack_door_is_silent_on_the_merge_path_when_not_a_stack_top(monkeypatch): + """#20251 -- controle negatif du meme chemin : rien a signaler, rien ne change. + + Sans lui, `test_stack_door_reaches_the_merge_gate_path` passerait aussi si + la porte refusait tout ce qu'elle voit. + """ + verdict, dossier = _ready_dossier() + snapshot = _snapshot(_body()) + monkeypatch.setattr(mod, "_git", _synthetic_git()) + monkeypatch.setattr(mod, "default_stack_index", lambda: _stack_index( + _stack_entry(snapshot["number"], {"a": "A"}), + _stack_entry(19556, {"z": "Z"}), + )) + + verdict, errors, dossier = mod.refute_ready_verdict( + snapshot, verdict, [], dossier, + probe=lambda pr: {"blocked": False, "blocking": []}, + ) + + assert verdict == mod.VERDICT_READY, errors + assert errors == [] and dossier is not None From a84168213290fd37dbdce1290867997556ef3fb4 Mon Sep 17 00:00:00 2001 From: jsboige Date: Sat, 10 Oct 2026 18:13:27 +0200 Subject: [PATCH 4/5] Fix(#20251): pile rebatie detectee par sujets quand les SHA sont reecrits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constat adjoint (dossier c6098840404) : _stack_partners exigeait une intersection de SHA non vide avant toute comparaison, alors que le body promet un appariement « par sujet, jamais par SHA » -- or un rebase reecrit precisement tous les SHA. Un sommet de pile rebati (le cas que la porte existe pour attraper) passait silencieusement. Repli par SUJETS partages (hors bruit) quand l'intersection de SHA est vide ; les controles aval -- silence d'ancetre lineaire, apport reel non-bruit -- restent appliques, le repli n'elargit que la detection du candidat. Option (a) de l'adjoint retenue (appariement resistant a la reecriture), temoins apparies : le sommet rebati echoue a la porte pre-fix, 217/217 post-fix. See #20263 Co-Authored-By: Claude Haiku 4.5 (1M context) --- scripts/check_adjoint_prevalidation.py | 13 ++- .../tests/test_check_adjoint_prevalidation.py | 91 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/scripts/check_adjoint_prevalidation.py b/scripts/check_adjoint_prevalidation.py index ef565ad05b..60387c1d78 100644 --- a/scripts/check_adjoint_prevalidation.py +++ b/scripts/check_adjoint_prevalidation.py @@ -1127,7 +1127,18 @@ def _stack_partners(target: int, index: dict[int, StackEntry]) -> list[int]: if not other.commits: continue if not set(entry.commits) & set(other.commits): - continue + # #20263 -- un rebase reecrit les SHA : une intersection vide + # ne prouve PAS l'independance, elle est exactement la + # signature du sommet de pile rebati que la porte existe pour + # attraper. Repli sur les SUJETS partages (hors bruit), comme + # le contrat publie le promet : « par sujet, jamais par SHA ». + # Les controles aval (ancetre lineaire, apport reel) restent + # appliques -- le repli n'elargit que la detection du candidat. + if not [ + s for s in (mine & _subjects_of(other)) + if not _is_stack_noise(s) + ]: + continue # Bas de pile lineaire : la tete de `target` est un ancetre de celle # de `other`, donc merger `target` d'abord est l'ordre correct. if _git( diff --git a/scripts/tests/test_check_adjoint_prevalidation.py b/scripts/tests/test_check_adjoint_prevalidation.py index 20507714fc..3c1a02ce08 100644 --- a/scripts/tests/test_check_adjoint_prevalidation.py +++ b/scripts/tests/test_check_adjoint_prevalidation.py @@ -3593,6 +3593,97 @@ def test_stack_noise_subjects_alone_do_not_block(monkeypatch): assert verdict == mod.VERDICT_READY, reasons +# #20263 -- un rebase reecrit les SHA : l'intersection vide de SHA ne +# prouve pas l'independance (trou mesure par l'adjoint, dossier +# c6098840404). Les temoins suivants couvrent le repli par SUJETS. + + +def test_stack_top_rebased_is_detected(monkeypatch): + """#20263 -- sommet REBATI : SHA aucun commun, sujets partages. + + Un rebase reecrit tous les SHA : pre-fix, l'intersection vide + ecartait la candidate et la porte rendait READY le bas de pile + perime -- exactement le cas que le contrat promet d'attraper + (« par sujet, jamais par SHA »). + """ + monkeypatch.setattr(mod, "_git", _synthetic_git()) + index = _stack_index( + _stack_entry(19548, {"a": "A", "b": "B"}), + _stack_entry(19556, {"x": "A", "y": "B", + "z": "D: correctif absent du bas de pile"}), + ) + snapshot = _base_snapshot() + snapshot["number"] = 19548 + verdict, reasons = mod.derive_verdict( + snapshot, lambda pr: {"blocked": False, "blocking": []}, stack=index + ) + assert verdict == mod.VERDICT_BLOCKED, reasons + assert any("stack top" in r and "#19556" in r for r in reasons), reasons + assert any("D: correctif absent du bas de pile" in r for r in reasons), reasons + + +def test_stack_rebased_linear_base_stays_silent(monkeypatch): + """#20263 -- bas de pile rebati LINEAIRE : le silence d'ancetre tient. + + Le repli par sujets ne court-circuite pas le controle d'ancetre : + meme rebatie (SHA disjoints), une base dont la tete est ancetre de + celle du sommet reste l'ordre de merge correct. + """ + bottom = mod.STACK_REF_PREFIX + "19548" + top = mod.STACK_REF_PREFIX + "19556" + monkeypatch.setattr(mod, "_git", _synthetic_git({(bottom, top)})) + index = _stack_index( + _stack_entry(19548, {"a": "A", "b": "B"}), + _stack_entry(19556, {"x": "A", "y": "B", "z": "C"}), + ) + snapshot = _base_snapshot() + snapshot["number"] = 19548 + verdict, reasons = mod.derive_verdict( + snapshot, lambda pr: {"blocked": False, "blocking": []}, stack=index + ) + assert verdict == mod.VERDICT_READY, reasons + assert reasons == [] + + +def test_stack_rebased_without_new_subject_is_not_blocked(monkeypatch): + """#20263 -- sujets partages sans APPORT reel : pas de blocage. + + Le repli herite du controle d'apport : une branche rebatie qui ne + porte aucun sujet nouveau n'est pas un sommet perime. + """ + monkeypatch.setattr(mod, "_git", _synthetic_git()) + index = _stack_index( + _stack_entry(19548, {"a": "A", "b": "B"}), + _stack_entry(19556, {"x": "A", "y": "B"}), + ) + verdict, reasons = mod.derive_verdict( + _base_snapshot(), lambda pr: {"blocked": False, "blocking": []}, stack=index + ) + assert verdict == mod.VERDICT_READY, reasons + assert reasons == [] + + +def test_stack_rebased_noise_only_shared_is_not_blocked(monkeypatch): + """#20263 -- sujets partages = bruit SEUL : le repli ne s'arme pas. + + Deux PRs dont le seul sujet commun est un vidage de re-declenchement + ne sont pas une pile, meme rebaties : le filtre de bruit s'applique + au repli par sujets comme au chemin SHA. + """ + monkeypatch.setattr(mod, "_git", _synthetic_git()) + index = _stack_index( + _stack_entry(19548, {"a": "A", + "b": "Fix: empty commit to retrigger CI"}), + _stack_entry(19556, {"x": "C", + "y": "Fix: empty commit to retrigger CI", + "z": "D"}), + ) + verdict, reasons = mod.derive_verdict( + _base_snapshot(), lambda pr: {"blocked": False, "blocking": []}, stack=index + ) + assert verdict == mod.VERDICT_READY, reasons + + def test_stack_vanished_head_ref_is_ignored(monkeypatch): """#20251 (4) -- ref de tete disparue : ecartee, jamais un incident. From f4d47a64b77701db88888032b9fd41f16583757f Mon Sep 17 00:00:00 2001 From: jsboige Date: Sun, 11 Oct 2026 18:33:35 +0200 Subject: [PATCH 5/5] Fix(#20263): corroborer le repli par sujets par le patch-id -- collision de titres fortuite ne bloque plus Reserve de l'adjoint (dossier c6105057753, controle negatif genuin-fortuit) : deux branches INDEPENDANTES depuis main, chacune un commit 'Fix: validation' de contenu different, etaient appariees par _stack_partners dans les DEUX sens et derive_verdict rendait BlocAGE mutuel. Le sujet partage seul ne prouve pas la parente. Le repli par sujets exige desormais la corroboration de CONTENU : au moins un sujet partage dont les commits des deux cotes partagent le meme patch-id (--stable), l'egalite qui survit au rebase. Mesure impossible (None) ne corrobore jamais -- pas de blocage fabrique. Limite documentee : un rebase a conflits resolus change le patch et echappe a la porte par sujets (le dossier exact-head reste le filet). Tests : negatif genuin-fortuit (echoue pre-fix, verifie) + mesure impossible + hermetisation des tests existants du repli (_patch_ids fake) ; positif rebase preserve (patch-ids egaux -> toujours BLOCKED) ; drivers git reel pre/post fix concordants. 219/219 verts. Co-Authored-By: Claude Sonnet 5.5 --- scripts/check_adjoint_prevalidation.py | 75 +++++++++++++++++- .../tests/test_check_adjoint_prevalidation.py | 76 ++++++++++++++++++- 2 files changed, 146 insertions(+), 5 deletions(-) diff --git a/scripts/check_adjoint_prevalidation.py b/scripts/check_adjoint_prevalidation.py index 60387c1d78..5a40903045 100644 --- a/scripts/check_adjoint_prevalidation.py +++ b/scripts/check_adjoint_prevalidation.py @@ -1103,6 +1103,64 @@ def _subjects_of(entry: StackEntry) -> set[str]: return set(entry.subjects.values()) +_PATCH_ID_CACHE: dict[str, str | None] = {} + + +def _patch_id(sha: str) -> str | None: + """``git patch-id --stable`` du commit, ou ``None`` si incalculable. + + Le patch-id est l'identite de CONTENU d'un commit : il survit au rebase + (un rebase reecrit les SHA, pas les patchs). C'est la corroboration que + le repli par sujets exige -- cf ``_stack_partners``. Vide de diff (commit + de re-declenchement), fusion ou SHA absent : ``None``, jamais une + exception -- une mesure impossible ne doit pas fabriquer de refus. + """ + if sha in _PATCH_ID_CACHE: + return _PATCH_ID_CACHE[sha] + # Pipeline unique : diff-tree produit le patch, patch-id le hache. Les + # deux vivent dans le meme sous-processus pour ne pas vehiculer le patch + # en Python (taille, encodage). + proc = subprocess.run( + f'git diff-tree -p {sha} | git patch-id --stable', + shell=True, capture_output=True, text=True, encoding="utf-8", + errors="replace", + ) + pid = proc.stdout.split()[0] if (proc.returncode == 0 and proc.stdout.split()) else None + _PATCH_ID_CACHE[sha] = pid + return pid + + +def _subjects_corroborated( + mine: StackEntry, other: StackEntry, shared_subjects: set[str] +) -> bool: + """Le repli par sujets est-il corrobore par le CONTENU des commits ? + + Sujets partages seul = collision de titres, pas une pile : deux branches + independantes depuis ``main`` portent souvent le meme prefixe canonique + (« Fix: validation ... »). Un sujet ne compte que si AU MOINS une paire + de commits (un de chaque cote, meme sujet) partage le meme patch-id -- + l'egalite qui survit au rebase. Un rebase a conflits resolu change le + patch : ce cas tres particulier n'est plus vu par la porte par sujets + (il est indiscernable d'une collision fortuite a ce niveau d'information + ; l'organe exact-head du dossier reste le filet). ``None`` (mesure + impossible) ne corrobore JAMAIS : echec de mesure => pas de blocage + fabrique. + """ + other_by_subject: dict[str, list[str]] = {} + for sha, subject in other.subjects.items(): + other_by_subject.setdefault(subject, []).append(sha) + for sha, subject in mine.subjects.items(): + if subject not in shared_subjects: + continue + mine_pid = _patch_id(sha) + if mine_pid is None: + continue + for other_sha in other_by_subject.get(subject, []): + if _patch_id(other_sha) == mine_pid: + return True + return False + + def _is_stack_noise(subject: str) -> bool: return subject.startswith("Merge ") or any( token in subject for token in STACK_NOISE_SUBJECTS @@ -1132,12 +1190,21 @@ def _stack_partners(target: int, index: dict[int, StackEntry]) -> list[int]: # signature du sommet de pile rebati que la porte existe pour # attraper. Repli sur les SUJETS partages (hors bruit), comme # le contrat publie le promet : « par sujet, jamais par SHA ». - # Les controles aval (ancetre lineaire, apport reel) restent - # appliques -- le repli n'elargit que la detection du candidat. - if not [ + # #20263 ( controle negatif de l'adjoint, dossier c6105057753) + # -- un sujet partage seul ne prouve pas non plus la parente : + # deux branches INDEPENDANTES depuis main portent souvent le + # meme prefixe canonique, et la porte les appariait en BlocAGE + # mutuel. Le repli exige la corroboration de CONTENU : au moins + # un sujet partage dont les commits des deux cotes ont le MEME + # patch-id (egalite qui survit au rebase). Les controles aval + # (ancetre lineaire, apport reel) restent appliques. + shared = [ s for s in (mine & _subjects_of(other)) if not _is_stack_noise(s) - ]: + ] + if not shared: + continue + if not _subjects_corroborated(entry, other, set(shared)): continue # Bas de pile lineaire : la tete de `target` est un ancetre de celle # de `other`, donc merger `target` d'abord est l'ordre correct. diff --git a/scripts/tests/test_check_adjoint_prevalidation.py b/scripts/tests/test_check_adjoint_prevalidation.py index 3c1a02ce08..a9dcb462cc 100644 --- a/scripts/tests/test_check_adjoint_prevalidation.py +++ b/scripts/tests/test_check_adjoint_prevalidation.py @@ -3507,6 +3507,19 @@ def _stack_entry(pr, subjects): ) +def _patch_ids(mapping): + """Fake ``mod._patch_id`` : sha -> id, hors mapping -> None. + + Le repli par sujets corrobore par le patch-id (#20263, controle negatif + de l'adjoint c6105057753) : les tests du repli patchent la mesure pour + rester hermetiques -- sans cela, la vraie mesure shellerait ``git`` sur + des SHA synthetiques et rendrait ``None`` (repli desactive de fait). + """ + def fake(sha): + return mapping.get(sha) + return fake + + def _stack_index(*entries): return {e.number: e for e in entries} @@ -3604,9 +3617,13 @@ def test_stack_top_rebased_is_detected(monkeypatch): Un rebase reecrit tous les SHA : pre-fix, l'intersection vide ecartait la candidate et la porte rendait READY le bas de pile perime -- exactement le cas que le contrat promet d'attraper - (« par sujet, jamais par SHA »). + (« par sujet, jamais par SHA »). Les patchs, eux, survivent au + rebase : la corroboration par patch-id (a<->x, b<->y) tient. """ monkeypatch.setattr(mod, "_git", _synthetic_git()) + monkeypatch.setattr( + mod, "_patch_id", _patch_ids({"a": "pA", "x": "pA", "b": "pB", "y": "pB"}) + ) index = _stack_index( _stack_entry(19548, {"a": "A", "b": "B"}), _stack_entry(19556, {"x": "A", "y": "B", @@ -3622,6 +3639,57 @@ def test_stack_top_rebased_is_detected(monkeypatch): assert any("D: correctif absent du bas de pile" in r for r in reasons), reasons +def test_stack_fortuitous_subject_collision_is_not_blocked(monkeypatch): + """#20263 (controle negatif de l'adjoint, dossier c6105057753) -- + collision de titres GENGIN-FORTUITE : pas de pile, pas de blocage. + + Deux branches INDEPENDANTES depuis main, chacune un commit « Fix: + validation » de contenu DIFFERENT (patch-ids distincts) et un apport + propre : le sujet partage seul ne corrobore rien. Pre-fix, la porte + appariait les deux dans CHAQUE sens et derive_verdict rendait BlocAGE + mutuel -- reproduit en git reel par le driver de la reserve. Post-fix, + le repli exige l'egalite de patch-id : aucune paire, verdict READY des + deux cotes. + """ + monkeypatch.setattr(mod, "_git", _synthetic_git()) + monkeypatch.setattr( + mod, "_patch_id", _patch_ids({"a": "pidA", "x": "pidX"}) + ) + index = _stack_index( + _stack_entry(19548, {"a": "Fix: validation", "b": "Add: apport A"}), + _stack_entry(19556, {"x": "Fix: validation", "y": "Add: apport B"}), + ) + for target in (19548, 19556): + snapshot = _base_snapshot() + snapshot["number"] = target + verdict, reasons = mod.derive_verdict( + snapshot, lambda pr: {"blocked": False, "blocking": []}, stack=index + ) + assert verdict == mod.VERDICT_READY, (target, reasons) + assert reasons == [], (target, reasons) + + +def test_stack_subject_match_unmeasured_patch_does_not_block(monkeypatch): + """#20263 -- mesure de patch-id impossible : jamais de blocage fabrique. + + Sujets partages mais patch-id incalculable des deux cotes (SHA + disparus, commits vides) : ``None`` ne corrobore pas -- la porte ne + refuse pas sur une mesure impossible. + """ + monkeypatch.setattr(mod, "_git", _synthetic_git()) + monkeypatch.setattr(mod, "_patch_id", _patch_ids({})) + index = _stack_index( + _stack_entry(19548, {"a": "A", "b": "B"}), + _stack_entry(19556, {"x": "A", "y": "B", "z": "C"}), + ) + snapshot = _base_snapshot() + snapshot["number"] = 19548 + verdict, reasons = mod.derive_verdict( + snapshot, lambda pr: {"blocked": False, "blocking": []}, stack=index + ) + assert verdict == mod.VERDICT_READY, reasons + + def test_stack_rebased_linear_base_stays_silent(monkeypatch): """#20263 -- bas de pile rebati LINEAIRE : le silence d'ancetre tient. @@ -3632,6 +3700,9 @@ def test_stack_rebased_linear_base_stays_silent(monkeypatch): bottom = mod.STACK_REF_PREFIX + "19548" top = mod.STACK_REF_PREFIX + "19556" monkeypatch.setattr(mod, "_git", _synthetic_git({(bottom, top)})) + monkeypatch.setattr( + mod, "_patch_id", _patch_ids({"a": "pA", "x": "pA", "b": "pB", "y": "pB"}) + ) index = _stack_index( _stack_entry(19548, {"a": "A", "b": "B"}), _stack_entry(19556, {"x": "A", "y": "B", "z": "C"}), @@ -3652,6 +3723,9 @@ def test_stack_rebased_without_new_subject_is_not_blocked(monkeypatch): porte aucun sujet nouveau n'est pas un sommet perime. """ monkeypatch.setattr(mod, "_git", _synthetic_git()) + monkeypatch.setattr( + mod, "_patch_id", _patch_ids({"a": "pA", "x": "pA", "b": "pB", "y": "pB"}) + ) index = _stack_index( _stack_entry(19548, {"a": "A", "b": "B"}), _stack_entry(19556, {"x": "A", "y": "B"}),