ouroboros/scripts/v7next_adoption.py
Ouroboros 8b2d1e6b6d rc.11 delta review: api-pack rows sized as production filters them, the advisory preview slot on its resolved route, a checked one-change invariant in the measurer, W4-F4 notes mirror the register
- devtools/measure_review_pack.py: the quorum limit and headroom are sized over
  the rows that RECEIVE the api pack — api_chat rows without a configured-
  subagent binding, decided by review_execution.delivery_retrieves exactly as
  review._prepare_unified_review decides before fit_triad_prompt; an all-
  retrieving panel reports 'no API pack is assembled for this panel' instead
  of a number (codex MAJOR). The 'index IS the working tree' comment became a
  checked invariant: an unstaged edit or an untracked file is the typed
  MeasuredCheckoutDirty refusal (exit 2), and the advisory arms are re-checked
  to resolve the same path set (fable MINOR).
- ouroboros/tools/claude_advisory_review.py: the native advisory slot is built
  by the dispatch builder (reviewer_slot_config.reviewer_slots), so use_local
  comes off the resolved route and a LOCAL advisory model previews its
  MANDATORY READ bound on its own window with the typed shortfall disclosure,
  instead of the remote/unknown route's (codex MAJOR); file stays at 1492 lines.
- ADOPTION_v7next.md / scripts/v7next_adoption.py: the Notes no longer call
  W4-F4 operator-disclosed; a 'Deferral authorities:' declaration mirrors
  DEFERRED_OUT_OF_V70 and the validator refuses a declaration that disagrees
  with the register (codex MINOR).
- docs/archive/v7next/LEDGER_CORRECTIONS.md: one-row note that the immutable
  band rationale for claude_advisory_review.py cites 1434 lines while the file
  stands at 1492 (fable minor).

Tests: tests/test_measure_review_pack.py (3 new, red-first), tests/test_advisory_route_pack.py (1 new, red-first), tests/test_v7next_adoption.py (2 new).

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
(cherry picked from commit eca294f4520802f2f3b64eaaa7e236d19899b4a5)
2026-09-04 23:55:32 +00:00

557 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Validate ADOPTION_v7next.md — the v7-side adoption manifest (Ф0 skeleton).
The manifest enumerates every v7-side delta that must be re-applied on top of
the v7next upstream base: the 18 approved semantic-delta families from the
frozen reference ledger (``ouroboros_v7_wip @ 9f691656`` —
``scripts/v7_migration.py::APPROVED_SEMANTIC_DELTAS`` minus ``"none"``) plus
the campaign-decision items of plan §6 (ABI package 7.0) and §7 (completeness)
and the plan §2 class returns.
Checks (plan §5.1, roast F2 — artifact/train-based manifest):
- the fixed 7-column table schema parses;
- ids are unique and well-formed;
- every required delta family D02–D38 is present as ``kind=semantic-delta``;
- ``kind`` / ``disposition`` / ``status`` / ``phase`` come from closed enums
(dispositions per the plan §5.4 three-column rule: retain / re-prove /
superseded-by-upstream, with ``pending-decision`` allowed only before
release);
- every row carries a non-empty verification hook;
- every post-cutoff upstream train the campaign absorbed keeps its row, still
naming its upstream tip and its campaign merge (both modes — a whole-file
overwrite deleted the sync #2 row past a bar that only the default mode ran);
- a ``done`` row's hook RESOLVES: every repo path exists and every ``::nodeid``
names something the file actually defines (read by AST);
- a ``done`` row does not say the work is open, unless it declares what stays
open in an explicit ``residual:`` clause;
- a post-release row's recorded authority and its text tell one story: an
OWNER deferral carries the owner's ``owner verbatim «…»`` quote in the row,
an operator disclosure carries none;
- the prose outside the table (header, schema, Notes) names ids only as the
table has them: every id-shaped token there resolves to a row unless the
prose declares it on a ``No-row ids: …`` line, and a declared no-row id must
not have a row (the Notes called W4-F3/W4-F4 rowless for two days after
d348ea46 made them rows — a green bar both days, because nothing read the
Notes). Disclosed residual: a rowless claim written as free English is not
read — a word marker was tried and misfired on «No row carries
pending-decision any more» — so the schema gives the claim its declared
form and the id resolution is the check that does not depend on wording;
- the Notes' ``Deferral authorities: <id> <authority>, …`` declaration (the
register's mirror) names exactly the ids ``DEFERRED_OUT_OF_V70`` records with
exactly their authority — the Notes called W4-F4 operator-disclosed for a day
after the register made it an owner deferral, and nothing read the Notes;
free prose about authority is still not read, the declared form is;
- ``--release``: no ``pending-decision`` dispositions and every row ``done``
("no unresolved rows at release", plan §10), with post-release rows leaving
the bar only through a recorded deferral (owner-authored for the required
inventory).
Exit 0 when green, 1 with findings, 2 when the manifest itself is missing or
structurally unparseable.
"""
from __future__ import annotations
import argparse
import ast
import pathlib
import re
import sys
from collections import Counter
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
MANIFEST = REPO_ROOT / "ADOPTION_v7next.md"
HEADER = ["id", "kind", "what", "disposition", "status", "phase", "verification hook"]
# APPROVED_SEMANTIC_DELTAS of the frozen reference, minus "none".
REQUIRED_DELTAS = (
"D02", "D03", "D04", "D05", "D06", "D07", "D08", "D09", "D11",
"D13", "D18", "D31", "D33", "D34", "D35", "D36", "D37", "D38",
)
# Required non-delta inventory (F0 phase review F2): the ABI package and the
# compatibility retirements are release-gated too, not only the D-families.
REQUIRED_ABI = tuple(f"ABI-{n}" for n in range(1, 11))
REQUIRED_CPL = tuple(f"CPL-{n}" for n in range(1, 8))
# F0 review round 2: the phase of every required row is itself part of the
# owner-approved inventory — a required row silently rescheduled to another
# phase (or parked post-release without a recorded deferral) must turn the
# validator red. DEFERRED_OUT_OF_V70 is that record: every post-release row
# must appear here, and a row of the owner-approved required inventory
# (REQUIRED_PHASE) may only be parked with OWNER authority, so flipping a
# required row post-release still cannot bypass the release bar. Operator
# authority covers rows that are disclosures rather than owner decisions —
# a defect a wave found and named instead of fixing.
REQUIRED_PHASE = {
# D02 F1->F3: owner-ratified F3 layout (2026-08-31) — the typed organ is
# re-derived whole by the F3.1 lane A; seam commit updates row + pin together.
# D03 F1->F6 (ADOPTION truth wave, 2026-09-01): F1 closed with the settings
# seam's rows 913-917/1080-1081 still hot-deferred, so the pin named a dead
# phase. F6 is the live phase. This is an OPERATOR scheduling correction, not
# an owner decision — disclosed in the manifest row and the ledger so the
# owner can overturn it. Sibling rows D04/D05/D06/D35 landed through their
# owner-decided lanes and read done; their F1 pins were deliberately left
# alone (one decision per class, and nobody has decided this one).
"D02": "F3", "D03": "F6", "D04": "F1", "D05": "F1", "D06": "F1",
"D07": "F2", "D08": "F2", "D09": "F1", "D11": "F1", "D13": "F1",
"D18": "F1", "D31": "F2", "D33": "F1", "D34": "F2", "D35": "F1",
"D36": "F2", "D37": "F2", "D38": "F1",
"ABI-1": "F3", "ABI-2": "F3", "ABI-3": "F3", "ABI-4": "F3",
"ABI-5": "F3", "ABI-6": "F3", "ABI-7": "F3", "ABI-8": "POST",
"ABI-9": "F3", "ABI-10": "F3",
"CPL-1": "F5", "CPL-2": "F5", "CPL-3": "F5", "CPL-4": "F5",
"CPL-5": "F5", "CPL-6": "F5", "CPL-7": "F5",
"DEFER-BROWSER": "POST",
}
OWNER, OPERATOR = "owner", "operator-disclosed"
DEFERRED_OUT_OF_V70 = {
# Owner decisions: Q5=A kept the handler ABI out of the bundle and Q16=A
# retired the «7.1» label into the post-release backlog (ABI-8); batch №9
# №14=A put the browser wave after the release, with a green smoke — not a
# green browser lane — as the condition on the tag (DEFER-BROWSER).
"ABI-8": OWNER,
"DEFER-BROWSER": OWNER,
# (W4-F1 and W4-F2 — the two evolution crash windows the F4 wave-4 lane
# disclosed instead of fixing — were pulled INTO 7.0 by owner batch №13
# item 9 = B, so they are no longer deferrals: their rows read done.)
# Owner-sanctioned deferrals that lived as prose inside done rows or in the
# ledger until the stage-2 bookkeeping made them rows (quotes in each row):
# batch №7 5=A (headless cancel receipts), batch №9 №12=A (two frozen modules),
# batch №12 A (C6 residuals), batch №8 5=A (task_results eternal).
# Operator disclosure WITHOUT an owner decision: the wave-4 observation
# W4-F4 only. Batch №13 item 13(и) asked to ratify W4-F3/W4-F4 and the owner
# answered that he had not read that item; the F3 owner batch of 2026-09-04
# re-asked, and its item 5 = A pulled W4-F3 INTO 7.0 (the marker is always
# written; that row reads done and is no longer a deferral). Owner-decided
# since batch №13:
# DEFER-E2E-PAID-LANE (item 2 = A ordered the paid lane RUN once — E1/E13
# executed green; E2/E3 stay unexecuted for a structural reason, the real
# Claudexor lane needs a logged-in Claude account, which is the owner's act
# — so the quote covers the execution and the remainder is a disclosed
# block, not a waiver) and DEFER-SPEC64-PATHS (item 8 = A). (Left this
# record by the same batch: item 10 = B pulled DEFER-TYPED-PROC-5 into 7.0;
# item 15 = B landed the mutating delegation scenarios S24/S25 —
# DEFER-E2E-DELEG-MUT reads done at phase F4; item 7 = A closed F23 as
# covered by the release bar.)
"DEFER-HEADLESS-CANCEL": OWNER,
"DEFER-FROZEN-2": OWNER,
"DEFER-C6-RESIDUALS": OWNER,
"DEFER-C19-RETENTION": OWNER,
"W4-F4": OWNER,
"DEFER-E2E-PAID-LANE": OWNER, # batch №13 item 2 = A (the run order); E2/E3 blocked structurally
"DEFER-SPEC64-PATHS": OWNER, # batch №13 item 8 = A
}
# Post-cutoff upstream adoption trains: id -> (upstream tip, campaign merge).
# A frozen inventory rather than a git derivation, and the history is the
# reason. Each sync's absorb merge does take its upstream tip as the literal
# second parent (20850191<-8d13373b, b9ceed6e<-f3fbfdbb, f4abe0a5<-a76961de),
# but only f4abe0a5 sits on this branch's first-parent line: the other two were
# made on lane lines and reached mainline on the second-parent side of a lane
# integration merge over a CAMPAIGN commit (0aa74e9f over 816e7b82, 0f9a8daf
# over 4c32691e). So a rule walking --first-parent merges and reading second
# parents would police one train of three and stay blind to the other two —
# exactly the hole that lost TRAIN-F6b-f3fbfdbb; widened to "second parent
# descends from a recorded upstream tip" it would demand a train row for every
# lane merge made after a sync (35 / 15 / 6 merges on this tree for the three
# tips), the C6 lane merge 9faccf31 over 8fb08d44 included. Neither is honest,
# and both need a subprocess. Sync #1 is recorded by its mainline carrier
# 0aa74e9f and names absorb merge 20850191 in the row text too; syncs #2 and #3
# are recorded by the absorb merge itself. Adding a train here is the same edit
# as merging one, and a deleted row is red at once.
REQUIRED_TRAINS = {
"TRAIN-F6-8d13373b": ("8d13373b", "0aa74e9f"),
"TRAIN-F6b-f3fbfdbb": ("f3fbfdbb", "b9ceed6e"),
"TRAIN-F6c-a76961de": ("a76961de", "f4abe0a5"),
}
KINDS = frozenset({"semantic-delta", "plan-item", "class-return"})
DISPOSITIONS = frozenset({"retain", "re-prove", "superseded-by-upstream",
"pending-decision", "post-release"})
STATUSES = frozenset({"pending", "in-progress", "done", "deferred"})
PHASES = frozenset({"F0", "F1", "F2", "F3", "F4", "F5", "F6", "POST"})
ID_RE = re.compile(r"^(D\d\d|ABI-\d+|CPL-\d+|R-[A-Z0-9]+|TRAIN-[A-Za-z0-9._-]+"
r"|DEFER-[A-Z0-9][A-Z0-9-]*|W\d-F\d+)$") # DEFER ids may carry hyphenated tokens (DEFER-E2E-PAID-LANE)
# The same id grammar, unanchored, for the prose outside the table. The
# boundaries keep `D-14` (a plan decision) and `CPL4-C6` (a lane label) out;
# the TRAIN class admits dots, so a sentence-final one is stripped by the reader.
_PROSE_ID_RE = re.compile(r"(?<![\w-])" + ID_RE.pattern[1:-1] + r"(?![\w-])")
# The one declared form for an id the prose names without a row (a folded or
# withdrawn row): a line `No-row ids: A, B`, optionally as a Notes bullet.
_NO_ROW_DECL_RE = re.compile(r"^\s*(?:-\s*)?No-row ids:(.*)$", re.M)
# The manifest's quote convention for an owner decision, as every OWNER row
# already spells it — the marker the authority lint keys on.
_OWNER_QUOTE_MARKER = "owner verbatim «"
# The one declared form for the deferral authorities the Notes claim — a
# `Deferral authorities…: <id> <authority>, …` bullet (continuation lines
# indented) mirroring DEFERRED_OUT_OF_V70. Free prose about authority is not read.
_DEFERRAL_DECL_RE = re.compile(
r"^\s*(?:-\s*)?Deferral authorities\b[^:]*:(?P<body>.*(?:\n[ \t]+(?!-\s).*)*)", re.M)
_DEFERRAL_PAIR_RE = re.compile(_PROSE_ID_RE.pattern + rf"\s+({OPERATOR}|{OWNER})\b")
def split_row(line: str) -> list[str]:
"""Split one markdown table row on unescaped pipes."""
body = line.strip().strip("|")
cells, cur, escaped = [], [], False
for ch in body:
if escaped:
cur.append(ch)
escaped = False
elif ch == "\\":
cur.append(ch)
escaped = True
elif ch == "|":
cells.append("".join(cur).strip())
cur = []
else:
cur.append(ch)
cells.append("".join(cur).strip())
return cells
def parse_rows(text: str) -> tuple[list[dict[str, str]], list[str]]:
errors: list[str] = []
rows: list[dict[str, str]] = []
lines = text.splitlines()
header_at = None
for i, line in enumerate(lines):
if line.startswith("|") and [c.lower() for c in split_row(line)] == HEADER:
header_at = i
break
if header_at is None:
errors.append(f"table header not found; expected columns: {' | '.join(HEADER)}")
return rows, errors
for j in range(header_at + 2, len(lines)):
line = lines[j]
if not line.startswith("|"):
break
cells = split_row(line)
if len(cells) != len(HEADER):
errors.append(f"line {j + 1}: expected {len(HEADER)} cells, got {len(cells)}")
continue
rows.append(dict(zip(HEADER, cells)))
return rows, errors
def manifest_prose(text: str) -> str:
"""Everything outside the table — the header, the schema, the Notes — by
the rule ``parse_rows`` already uses: a table line starts with ``|``."""
return "\n".join(line for line in text.splitlines() if not line.startswith("|"))
def _prose_id_errors(prose: str, by_id: dict[str, dict[str, str]]) -> list[str]:
"""The prose may name a row id only as the table has it. Tokens are read by
the table's own id grammar, not by phrasing, so the check does not depend
on how a sentence says 'gets no row': an id without a row must be declared
on a ``No-row ids:`` line, and a declared id must not have a row."""
declared: set[str] = set()
for m in _NO_ROW_DECL_RE.finditer(prose):
declared.update(t.rstrip(".-") for t in _PROSE_ID_RE.findall(m.group(1)))
named = {t.rstrip(".-") for t in _PROSE_ID_RE.findall(prose)}
errors: list[str] = []
for rid in sorted(declared & by_id.keys()):
errors.append(f"prose: {rid} is declared under 'No-row ids:' while the "
"table has its row — drop the declaration or the row")
for rid in sorted(named - by_id.keys() - declared):
errors.append(f"prose: {rid} is named outside the table but has no row "
"and no 'No-row ids:' declaration")
return errors
def declared_deferral_authorities(prose: str) -> dict[str, str] | None:
"""The Notes' ``Deferral authorities:`` declaration as ``{id: authority}``;
``None`` when the prose carries no declaration."""
m = _DEFERRAL_DECL_RE.search(prose)
if m is None:
return None
return dict(_DEFERRAL_PAIR_RE.findall(m.group("body")))
def _deferral_declaration_errors(prose: str) -> list[str]:
"""A declared deferral-authority list must match ``DEFERRED_OUT_OF_V70``
exactly — the same ids, each with its recorded authority — so the Notes
cannot tell a different story than the register."""
declared = declared_deferral_authorities(prose)
if declared is None:
return []
errors: list[str] = []
for rid in sorted(declared.keys() - DEFERRED_OUT_OF_V70.keys()):
errors.append(f"prose: Deferral authorities declares {rid}, which "
"DEFERRED_OUT_OF_V70 does not record")
for rid in sorted(DEFERRED_OUT_OF_V70.keys() - declared.keys()):
errors.append(f"prose: Deferral authorities omits {rid} "
f"({DEFERRED_OUT_OF_V70[rid]} in DEFERRED_OUT_OF_V70) — declare every recorded deferral")
for rid in sorted(declared.keys() & DEFERRED_OUT_OF_V70.keys()):
if declared[rid] != DEFERRED_OUT_OF_V70[rid]:
errors.append(f"prose: Deferral authorities says {rid} is {declared[rid]} while "
f"DEFERRED_OUT_OF_V70 records {DEFERRED_OUT_OF_V70[rid]}")
return errors
def validate(rows: list[dict[str, str]], release: bool, prose: str = "") -> list[str]:
errors: list[str] = []
ids = [r["id"] for r in rows]
for rid, n in Counter(ids).items():
if n > 1:
errors.append(f"duplicate id: {rid} ({n} rows)")
for r in rows:
rid = r["id"]
if not ID_RE.match(rid):
errors.append(f"{rid or '<empty>'}: malformed id")
if r["kind"] not in KINDS:
errors.append(f"{rid}: unknown kind {r['kind']!r}")
if r["disposition"] not in DISPOSITIONS:
errors.append(f"{rid}: unknown disposition {r['disposition']!r}")
if r["status"] not in STATUSES:
errors.append(f"{rid}: unknown status {r['status']!r}")
if r["phase"] not in PHASES:
errors.append(f"{rid}: unknown phase {r['phase']!r}")
if not r["what"]:
errors.append(f"{rid}: empty 'what'")
if not r["verification hook"]:
errors.append(f"{rid}: empty verification hook")
by_id = {r["id"]: r for r in rows}
for d in REQUIRED_DELTAS:
row = by_id.get(d)
if row is None:
errors.append(f"required semantic delta {d} is missing")
elif row["kind"] != "semantic-delta":
errors.append(f"{d}: must be kind=semantic-delta, got {row['kind']!r}")
# F0 phase review F2: the ABI package and compatibility retirements are part
# of the release inventory too — deleting their rows must turn --release red.
for rid in (*REQUIRED_ABI, *REQUIRED_CPL):
row = by_id.get(rid)
if row is None:
errors.append(f"required row {rid} is missing")
elif row["kind"] != "plan-item":
errors.append(f"{rid}: must be kind=plan-item, got {row['kind']!r}")
# Row-specific coupling: post-release is a single coherent state, not three
# independent knobs (prevents e.g. disposition=post-release with status=done
# quietly counting as shipped) — and it needs a recorded deferral in
# DEFERRED_OUT_OF_V70, owner-authored for the required inventory, so
# flipping a required row to post-release cannot bypass the release bar.
for r in rows:
post_bits = [r["disposition"] == "post-release", r["status"] == "deferred",
r["phase"] == "POST"]
if any(post_bits) and not all(post_bits):
errors.append(
f"{r['id']}: post-release rows need disposition=post-release + "
f"status=deferred + phase=POST together, got "
f"{r['disposition']}/{r['status']}/{r['phase']}")
if all(post_bits):
authority = DEFERRED_OUT_OF_V70.get(r["id"])
if authority is None:
errors.append(
f"{r['id']}: post-release needs a recorded deferral in "
f"DEFERRED_OUT_OF_V70 (currently "
f"{sorted(DEFERRED_OUT_OF_V70)})")
elif authority != OWNER and r["id"] in REQUIRED_PHASE:
errors.append(
f"{r['id']}: a row of the required inventory can only be "
f"parked post-release by an owner decision, not by "
f"{authority}")
# The record and the row tell one story: an owner deferral carries
# the owner's quote, an operator disclosure carries none. The
# comment block over the record drifted from its values once
# (E2/E3 and spec §6.4 read operator-disclosed beside OWNER), and
# a reader trusts the prose first.
quoted = _OWNER_QUOTE_MARKER in r["what"]
if authority == OWNER and not quoted:
errors.append(
f"{r['id']}: recorded as an owner deferral but the row carries "
f"no '{_OWNER_QUOTE_MARKER}…»' quote — quote the decision or "
f"record the row as {OPERATOR}")
elif authority == OPERATOR and quoted:
errors.append(
f"{r['id']}: recorded as {OPERATOR} but the row carries an owner "
f"quote — record the row as {OWNER} or drop the quote")
# Every upstream train the campaign absorbed must keep its row, and the row
# must still name the tip and the merge it is a record of. Both modes: the
# deletion in 285ab66d survived because only the default mode was run.
for rid, (tip, merge) in REQUIRED_TRAINS.items():
row = by_id.get(rid)
if row is None:
errors.append(f"required upstream train {rid} is missing — every "
"absorbed upstream train keeps a row")
continue
if row["kind"] != "plan-item":
errors.append(f"{rid}: must be kind=plan-item, got {row['kind']!r}")
text = f"{row['what']} {row['verification hook']}"
for sha in (tip, merge):
if sha not in text:
errors.append(f"{rid}: row text no longer names {sha} "
"(upstream tip and campaign merge are what the "
"row records)")
# A shipped row must not say it is unshipped. This is a text-vs-cell
# consistency lint on an operator manifest — not a semantic gate on any
# runtime decision — and the `residual:` clause is the explicit escape, so
# a genuine disclosure on a shipped row stays sayable.
for r in rows:
errors.extend(_honesty_errors(r))
# Hook resolution is a property of a shipped row, not of the release
# invocation, so it runs in both modes and its messages say `hook:`.
# The manifest's own Notes state the same rule for its readers.
if r["status"] == "done":
errors.extend(_hook_resolution_errors(r))
# Phase pinning of the required inventory.
for rid, want in REQUIRED_PHASE.items():
row = by_id.get(rid)
if row is not None and row["phase"] != want:
errors.append(f"{rid}: phase {row['phase']!r} != pinned {want!r} "
"(rescheduling a required row needs a new owner decision "
"and an update to REQUIRED_PHASE)")
if prose:
errors.extend(_prose_id_errors(prose, by_id))
errors.extend(_deferral_declaration_errors(prose))
if release:
for r in rows:
if r["disposition"] == "pending-decision":
errors.append(f"release: {r['id']} still pending-decision")
if r["disposition"] == "post-release":
continue # explicitly deferred out of v7.0 by an owner decision
if r["status"] != "done":
errors.append(f"release: {r['id']} status {r['status']!r} != done")
return errors
# Any-extension token, anchored on BOTH sides: the lookbehind stops
# `not-scripts/x.py` being misread as a scripts/ reference (round 4), the
# lookahead stops `scripts/x.py-not-real` matching by its existing `.py`
# prefix (round 5) — a partial token is prose, not a reference.
_HOOK_PATH_RE = re.compile(r"(?<![\w./-])(?:tests|scripts|docs)/[\w./-]+\.\w+(?![\w-])")
# A shipped row that says the work is open contradicts its own status cell.
# The escape is explicit and named, not a keyword exception list.
_NOT_DONE_MARKERS = ("not done", "open residual", "not integrated yet",
"still owed", "read pending")
_RESIDUAL_CLAUSE = "residual:"
def _honesty_errors(row: dict[str, str]) -> list[str]:
"""A `done` row may carry an open residual — that is what a `residual:`
clause declares — but it may not say the work itself is not done."""
if row["status"] != "done":
return []
text = f"{row['what']} {row['verification hook']}".lower()
if _RESIDUAL_CLAUSE in text:
return []
hits = [m for m in _NOT_DONE_MARKERS if m in text]
if not hits:
return []
return [f"{row['id']}: status is done while the text says {hits!r}; either "
f"the status is wrong or the row needs an explicit "
f"'{_RESIDUAL_CLAUSE}' clause naming what stays open"]
# A hook may name a pytest node id. The path half was already resolved; the
# `::name` half was free text until now, so a hook could point at a suite that
# exists and a pin that does not.
_HOOK_NODEID_RE = re.compile(
r"(?<![\w./-])((?:tests|scripts)/[\w./-]+\.py)((?:::[A-Za-z_]\w*)+)")
def _defined_names(path: pathlib.Path) -> set[str]:
"""Every name a pytest node id could legitimately address in a file:
functions and classes at any depth (``path::Class::method``) plus
module-level bindings — a hook may name the closed inventory a pin drives,
not only the pin (`tests/_shared.py::SETTINGS_WRITERS`)."""
tree = ast.parse(path.read_text(encoding="utf-8"))
names: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
names.add(node.name)
for node in tree.body:
targets = node.targets if isinstance(node, ast.Assign) else (
[node.target] if isinstance(node, ast.AnnAssign) else [])
names.update(t.id for t in targets if isinstance(t, ast.Name))
return names
def _hook_nodeid_errors(row: dict[str, str], hook: str) -> list[str]:
errors: list[str] = []
for rel, tail in _HOOK_NODEID_RE.findall(hook):
path = (REPO_ROOT / rel).resolve()
if not path.is_file():
continue # the path half is reported by the resolver above
try:
defined = _defined_names(path)
except SyntaxError as exc: # unparseable file: say so, do not pass it
errors.append(f"hook: {row['id']} hook file {rel} does not parse ({exc})")
continue
for part in tail.split("::"):
if part and part not in defined:
errors.append(f"hook: {row['id']} hook names {rel}::{part}, "
f"which {rel} does not define")
return errors
def _hook_resolution_errors(row: dict[str, str]) -> list[str]:
"""Shipped-row hook contract (F0 review rounds 1-4): a shipped row's
verification hook must RESOLVE — prose alone cannot pass. At least one
repo-path reference must be present, EVERY referenced token must exist
(any extension — a smuggled bogus reference next to a valid one is an
error, not ignored), and the path must stay inside its top directory
(`tests/../x` traversal is rejected). This runs for every `done` row in
BOTH modes — it is a property of a shipped row, not of the --release
invocation — so the messages are prefixed `hook:`, not `release:`. A row
that is not yet `done` keeps a free-prose hook, naming the suite the work
will land in."""
hook = row["verification hook"]
paths = _HOOK_PATH_RE.findall(hook.replace("\\|", "|"))
errors: list[str] = []
if not paths:
errors.append(
f"hook: {row['id']} hook has no resolvable repo-path reference "
"(tests/, scripts/ or docs/ file) — prose-only hooks cannot ship")
for p in paths:
top = p.split("/", 1)[0]
candidate = (REPO_ROOT / p).resolve()
top_root = (REPO_ROOT / top).resolve()
# pathlib containment, not string prefixing: portable across
# separators (round 5: the "/"-suffix check broke on Windows).
inside = candidate == top_root or top_root in candidate.parents
if ".." in p.split("/") or not inside:
errors.append(f"hook: {row['id']} hook path escapes {top}/: {p}")
elif not candidate.is_file():
errors.append(f"hook: {row['id']} hook references missing file {p}")
errors.extend(_hook_nodeid_errors(row, hook))
return errors
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--release", action="store_true",
help="enforce the release bar: no pending-decision, all rows done")
ap.add_argument("--manifest", type=pathlib.Path, default=MANIFEST)
args = ap.parse_args()
if not args.manifest.is_file():
print(f"missing manifest: {args.manifest}", file=sys.stderr)
return 2
text = args.manifest.read_text(encoding="utf-8")
rows, errors = parse_rows(text)
if not rows and errors:
for e in errors:
print(f"ERROR: {e}", file=sys.stderr)
return 2
errors += validate(rows, args.release, prose=manifest_prose(text))
by_phase = Counter(r["phase"] for r in rows)
by_disp = Counter(r["disposition"] for r in rows)
by_status = Counter(r["status"] for r in rows)
by_kind = Counter(r["kind"] for r in rows)
print(f"{args.manifest.name}: {len(rows)} rows")
print(f" kind: {dict(sorted(by_kind.items()))}")
print(f" phase: {dict(sorted(by_phase.items()))}")
print(f" disposition: {dict(sorted(by_disp.items()))}")
print(f" status: {dict(sorted(by_status.items()))}")
if errors:
for e in errors:
print(f"ERROR: {e}", file=sys.stderr)
return 1
print("OK" + (" (release bar)" if args.release else ""))
return 0
if __name__ == "__main__":
sys.exit(main())