diff --git a/scripts/check_adjoint_prevalidation.py b/scripts/check_adjoint_prevalidation.py index 276c634652..013bd8ccac 100644 --- a/scripts/check_adjoint_prevalidation.py +++ b/scripts/check_adjoint_prevalidation.py @@ -135,6 +135,20 @@ EXIT_NO_DOSSIER = 1 EXIT_UNKNOWN = 2 EXIT_BLOCKED_WITH_SUBSTANCE = 3 +# The four contract fields that must sit at their READY value for a dossier to +# claim the pull request is mergeable (see validate_dossier, which enforces them +# exactly when `verdict: READY`). Their COMPLEMENT on an intact dossier is the +# attested reason -- and it is what the gate used to throw away: the rule tells +# the coordinator to "dispatch from the dossier's stated reason" (#17290) while +# exit 3 published an `errors: []` that is empty BY CONSTRUCTION, the dossier +# being intact. Order is the contract's own, so two dossiers blocked for the +# same reason render identically and the dispatch is groupable. +BLOCKING_FIELDS = ( + ("checks", ("latest-wins-green",)), + ("b0", ("clear",)), + ("scope", ("pass",)), + ("domain", ("pass", "not-applicable")), +) # Conclusions that do not refute `checks: latest-wins-green`. `skipped` and # `neutral` are not failures; anything else completed (failure, timed_out, # cancelled, action_required, startup_failure, stale...) does (#16957). @@ -697,16 +711,24 @@ def validate_dossier(dossier: Dossier, snapshot: dict[str, Any]) -> list[str]: return errors -def evaluate(snapshot: dict[str, Any]) -> tuple[str, list[str]]: +def evaluate_with_dossier( + snapshot: dict[str, Any], +) -> tuple[str, list[str], Dossier | None]: """Select the newest candidate and return a fail-closed verdict. Returns one of ``VERDICT_READY`` (intact dossier claiming the PR is mergeable), ``VERDICT_BLOCKED`` (intact dossier attesting it is not) or - ``""`` (no dossier worth trusting). Separating dossier integrity from PR - mergeability is the whole point: making the right to READ depend on the - state of MERGEABILITY meant the coordinator could only ever open the pull - requests that were already fine -- never the oldest ones, which are old - precisely because they are blocked. + ``""`` (no dossier worth trusting), plus the dossier that produced the + verdict -- ``None`` whenever the verdict is ``""``. Separating dossier + integrity from PR mergeability is the whole point: making the right to READ + depend on the state of MERGEABILITY meant the coordinator could only ever + open the pull requests that were already fine -- never the oldest ones, + which are old precisely because they are blocked. + + The third element is what makes the verdict ACTIONABLE. On + ``VERDICT_BLOCKED`` the ``errors`` list is empty by construction -- the + dossier is intact, that is what exit 3 means -- so the reason the gate read + has to travel separately or not at all (#17290). """ comments = snapshot.get("comments") or [] candidates: list[tuple[Dossier, list[str]]] = [] @@ -721,7 +743,7 @@ def evaluate(snapshot: dict[str, Any]) -> tuple[str, list[str]]: candidates.append((dossier, parse_errors)) if not candidates: - return "", ["no [ADJOINT PREFLIGHT] dossier comment found"] + return "", ["no [ADJOINT PREFLIGHT] dossier comment found"], None dossier, errors = candidates[-1] errors = [*errors, *validate_dossier(dossier, snapshot)] @@ -738,8 +760,74 @@ def evaluate(snapshot: dict[str, Any]) -> tuple[str, list[str]]: "discussion changed after dossier: a fresh adjoint preflight is required" ) if errors: - return "", errors - return dossier.fields.get("verdict", ""), [] + return "", errors, None + return dossier.fields.get("verdict", ""), [], dossier + + +def evaluate(snapshot: dict[str, Any]) -> tuple[str, list[str]]: + """The verdict and the reason, without the dossier (the historical shape). + + Kept as the callers' view so the gate's contract does not move under them; + ``evaluate_with_dossier`` is the same computation plus what #17290 exposes. + """ + verdict, errors, _ = evaluate_with_dossier(snapshot) + return verdict, errors + + +def blocking_fields(dossier: Dossier) -> list[str]: + """The attested fields that are NOT at their READY value -- the reason. + + A ``BLOCKED`` dossier may legitimately have none of them: `validate_dossier` + constrains these four only when the dossier CLAIMS ready, so an honest + blocked dossier can declare `checks: latest-wins-green` and carry its reason + in prose. This returns ``[]`` then -- it names the fields that block, and + never invents one to fill the silence. + """ + fields = dossier.fields + return [ + key for key, ready_values in BLOCKING_FIELDS + if fields.get(key, "") not in ready_values + ] + + +def dossier_payload(dossier: Dossier) -> dict[str, Any]: + """Every field the gate READ, plus where it read it. + + This publishes what `parse_dossier` already parsed: the dossier contract + itself is unchanged, no field is added to what an emitting lane must write. + """ + payload: dict[str, Any] = dict(dossier.fields) + payload["author"] = dossier.author + payload["created_at"] = dossier.created_at + payload["comment_index"] = dossier.comment_index + return payload + + +def build_result( + pr: int, + snapshot: dict[str, Any], + verdict: str, + errors: list[str], + dossier: Dossier | None, +) -> dict[str, Any]: + """The ``--json`` payload, built without touching the network. + + The `dossier` block rides ONLY with a verdict the gate accepted. A refused + dossier must keep reading as refused: exposing the attested reason must not + make a PR whose fingerprint is broken look prevalidated -- the negative + control of #17290. + """ + result: dict[str, Any] = { + "pr": pr, + "head": snapshot["headRefOid"], + "ready": verdict == VERDICT_READY, + "verdict": verdict or "NO_DOSSIER", + "errors": errors, + } + if dossier is not None and verdict: + result["dossier"] = dossier_payload(dossier) + result["blocking_fields"] = blocking_fields(dossier) + return result def review_threads(pr: int) -> list[dict[str, Any]]: @@ -994,7 +1082,7 @@ def main() -> int: file=sys.stderr, ) return 0 - verdict, errors = evaluate(snapshot) + verdict, errors, dossier = evaluate_with_dossier(snapshot) except ( RuntimeError, KeyError, @@ -1014,13 +1102,7 @@ def main() -> int: return EXIT_UNKNOWN ready = verdict == VERDICT_READY - result = { - "pr": args.pr, - "head": snapshot["headRefOid"], - "ready": ready, - "verdict": verdict or "NO_DOSSIER", - "errors": errors, - } + result = build_result(args.pr, snapshot, verdict, errors, dossier) if args.json: print(json.dumps(result, ensure_ascii=False)) elif ready: @@ -1031,6 +1113,11 @@ def main() -> int: f"{snapshot['headRefOid']} attesting it is NOT mergeable." ) print(" Do not open its surfaces: dispatch from the dossier's stated reason.") + attested = ", ".join( + f"{key}={dossier.fields.get(key, '')}" for key, _ in BLOCKING_FIELDS + ) + blocked = ",".join(blocking_fields(dossier)) or "none named by the contract" + print(f" attested reason: {attested} (blocking: {blocked})") else: print(f"NO-DOSSIER -- PR #{args.pr} is not adjoint-prevalidated") for error in errors: diff --git a/scripts/tests/test_check_adjoint_prevalidation.py b/scripts/tests/test_check_adjoint_prevalidation.py index 4c60ce5337..cf780d0cdf 100644 --- a/scripts/tests/test_check_adjoint_prevalidation.py +++ b/scripts/tests/test_check_adjoint_prevalidation.py @@ -930,6 +930,136 @@ def test_template_renders_the_emitting_lane_not_a_borrowed_name(): def test_template_lane_defaults_to_the_adjoint(): """The adjoint stays the canonical emitter: the default is unchanged.""" assert f"lane: {mod.ADJOINT_LANE}" in mod.render_template(_base_snapshot()) + + +# ------------------------------------------- the attested reason, exposed (#17290) +# +# On exit 3 the dossier is INTACT -- that is what the code means -- so `errors` +# is [] by construction. The rule then tells the coordinator to "dispatch from +# the dossier's stated reason" while publishing none of it, and re-parsing the +# dossier comment by hand is the only way out. Measured cost: 13 PRs at rc=3 in +# one cycle, 6 of them with a reason that was already dead. +# +# Exposing it must not weaken the gate: a REFUSED dossier is not a fainter +# dossier, and must publish nothing at all. + + +def test_blocking_fields_name_checks_on_a_checks_blocked_dossier(): + """Controle positif 1 : `checks: BLOCKED`, `b0: clear` -> ["checks"].""" + snapshot = _snapshot(_body(verdict="BLOCKED", checks="BLOCKED")) + verdict, errors, dossier = mod.evaluate_with_dossier(snapshot) + assert (verdict, errors) == (mod.VERDICT_BLOCKED, []) + assert mod.blocking_fields(dossier) == ["checks"] + + +def test_blocking_fields_name_b0_on_a_b0_blocked_dossier(): + """Controle positif 2 : `b0: blocked`, `checks: latest-wins-green` -> ["b0"]. + + The two reasons send the work to DIFFERENT places -- a dead `checks` goes to + the adjoint for a `--template`, a real `b0` goes to the carrying lane for a + lift sentence. Telling them apart is the whole point of the exposure. + """ + snapshot = _snapshot(_body(verdict="BLOCKED", b0="blocked")) + verdict, errors, dossier = mod.evaluate_with_dossier(snapshot) + assert (verdict, errors) == (mod.VERDICT_BLOCKED, []) + assert mod.blocking_fields(dossier) == ["b0"] + + +def test_blocking_fields_are_listed_in_the_contract_order(): + """A dossier blocked on every field orders them the way the contract does.""" + snapshot = _snapshot( + _body(verdict="BLOCKED", checks="BLOCKED", b0="blocked", scope="fail", + domain="fail") + ) + _, _, dossier = mod.evaluate_with_dossier(snapshot) + assert mod.blocking_fields(dossier) == ["checks", "b0", "scope", "domain"] + + +def test_a_blocked_dossier_can_name_no_blocking_field(): + """Honesty edge: the contract ALLOWS a blocked dossier with clear fields. + + `validate_dossier` constrains `checks`/`b0`/`scope`/`domain` only when the + dossier claims READY. An honest blocked dossier may therefore declare them + all at their READY value and carry its reason in prose. Reporting [] then is + the true answer -- inventing a field to fill the silence would fabricate the + very reason this issue exists to publish. + """ + snapshot = _snapshot(_body(verdict="BLOCKED")) + verdict, errors, dossier = mod.evaluate_with_dossier(snapshot) + assert (verdict, errors) == (mod.VERDICT_BLOCKED, []) + assert mod.blocking_fields(dossier) == [] + + +def test_ready_result_publishes_the_dossier_with_no_blocker(): + """Acceptance: rc=0 carries the same block, with `blocking_fields: []`.""" + snapshot = _snapshot(_body()) + verdict, errors, dossier = mod.evaluate_with_dossier(snapshot) + result = mod.build_result(123, snapshot, verdict, errors, dossier) + assert result["verdict"] == mod.VERDICT_READY + assert result["ready"] is True + assert result["blocking_fields"] == [] + assert result["dossier"]["checks"] == "latest-wins-green" + + +def test_blocked_result_publishes_the_dossier_and_its_blocker(): + """Acceptance: rc=3 exposes the parsed dossier and a non-empty blocker.""" + snapshot = _snapshot(_body(verdict="BLOCKED", checks="BLOCKED")) + verdict, errors, dossier = mod.evaluate_with_dossier(snapshot) + result = mod.build_result(123, snapshot, verdict, errors, dossier) + assert result["verdict"] == mod.VERDICT_BLOCKED + assert result["blocking_fields"] == ["checks"] + assert result["dossier"]["lane"] == "myia-po-2025:CoursIA-2" + assert result["dossier"]["head"] == HEAD + assert result["dossier"]["comment_index"] == 1 + + +def test_a_refused_dossier_is_not_published_at_all(): + """Controle NEGATIF -- the exposure must not launder a refused dossier. + + A dossier whose fingerprint no longer covers the live surfaces is refused + (rc=1). If the payload published its parsed fields anyway, a referee reading + the JSON would see a lane, a head and a verdict for a PR the gate just + rejected -- exactly the confusion the refusal exists to prevent. + """ + snapshot = _snapshot(_body()) + snapshot["title"] = "mutated after the stamp" + verdict, errors, dossier = mod.evaluate_with_dossier(snapshot) + assert verdict == "" and dossier is None + assert errors, "a perimed fingerprint must still be refused" + result = mod.build_result(123, snapshot, verdict, errors, dossier) + assert result["verdict"] == "NO_DOSSIER" + assert "dossier" not in result + assert "blocking_fields" not in result + + +def test_dossier_payload_publishes_every_read_field(): + """Acceptance: "tous les champs lus" -- the whole contract, not a subset. + + Publishing a curated handful would leave the next consumer re-parsing the + comment for the field that was left out, which is the defect itself. + """ + dossier, parse_errors = mod.parse_dossier(_body(), 1, "jsboige", T0) + assert parse_errors == [] + payload = mod.dossier_payload(dossier) + assert mod.REQUIRED_FIELDS <= set(payload) + assert payload["author"] == "jsboige" + assert payload["created_at"] == T0 + + +def test_result_is_json_serializable(): + """`--json` must actually serialize: a Dossier is not a dict.""" + snapshot = _snapshot(_body(verdict="BLOCKED", b0="blocked")) + verdict, errors, dossier = mod.evaluate_with_dossier(snapshot) + result = mod.build_result(123, snapshot, verdict, errors, dossier) + decoded = json.loads(json.dumps(result, ensure_ascii=False)) + assert decoded["dossier"]["b0"] == "blocked" + + +def test_evaluate_keeps_its_two_tuple_shape(): + """The gate's historical view is unchanged: no caller moves under it.""" + verdict, errors = mod.evaluate(_snapshot(_body())) + assert (verdict, errors) == (mod.VERDICT_READY, []) + # --- #16931 : reecriture en place d'un bot marker-garde = fingerprint STABLE --