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
121 changes: 104 additions & 17 deletions scripts/check_adjoint_prevalidation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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]]] = []
Expand All @@ -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)]
Expand All @@ -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]]:
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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:
Expand Down
130 changes: 130 additions & 0 deletions scripts/tests/test_check_adjoint_prevalidation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 --


Expand Down
Loading