Files
imbad0202__academic-researc…/scripts/check_v3_6_8_audit_scope_block.py
Edward Cheng-I Wu fed3eba117 v3.7.1 Step 2: D2 audit Scope Report block + lint enforcement (#78)
* feat(v3.7.1 Step 2): D2 audit Scope Report block + lint enforcement

Spec: docs/design/2026-04-30-ars-v3.6.8-trust-provenance-and-drift-
transparency-spec.md §3.2 (D2 — Audit scope coverage non-disclosure),
§3.7 (v3.6.7 invariant overlap audit), §4 Step 2.

Pattern D2 closes the "PASSED without disclosing 22/31 verification
split" failure mode observed in the 2026-04-30 HEEACT chapter session.
Codex round 3 reported "ADDRESSED" without surfacing that only 22 of
53 entries had retrieved original sources; the remaining 31 description-
only entries inherited the verified verdict by aggregation.

Changes (additive only — Sections 1-7 of the audit template stay byte-
equivalent in title, ordinal label, and order per Q5 amend):

- shared/templates/codex_audit_multifile_template.md: prepend Section 0
  Scope Report block ahead of Section 1, plus Template-structure prose
  update from "seven sections" to "eight sections" (Section 0 listed
  with v3.7.1 D2 origin tag). Section 0 carries the four required
  content fields (Total entries audited / Entries with retrieved
  original source / Entries description-only / Audit scope warning)
  and the two firm rules (must appear before any pass/fail summary;
  combined-aggregate "PASSED" verb forbidden in audit summary —
  aggregate verdict MUST be split into verified-against-source /
  description-internally-consistent / unaudited-due-to-missing-source).

- scripts/check_v3_6_8_audit_scope_block.py: new lint enforcing five
  rules (R1 Section 0 anchor + Scope Report header strictly before
  Section 1; R2 four required content fields; R3 three required
  aggregate-status splits; R4 forbidden combined-aggregate "PASSED"
  verb in summary; R5 Section 1 byte-equivalence sentinel for the
  additive-prepend invariant). Issue-#77-aware design: heading-
  anchored extraction (not first-match re.search), duplicate H2
  marker rejection, fenced-code-block exclusion for top-level
  anchor detection.

- scripts/test_check_v3_6_8_audit_scope_block.py: 11 mutation tests
  (T1-T8 covering positive baseline + 6 negative mutation classes;
  T8 parametrized across the 4 required content fields). Snapshot+
  mutate+restore pattern mirrors test_check_v3_6_8_pattern_protection.py.

- .github/workflows/spec-consistency.yml: wire new lint + pytest into
  CI (mirroring v3.6.8 SHA gate / frontmatter trust schema pattern).

Three-lint regression gate (spec §3.7 line 367-372) PASSES on the
modified template:
  python scripts/check_v3_6_7_pattern_protection.py        → 12/12
  pytest scripts/test_check_v3_6_7_pattern_protection.py   → 58/58
  python scripts/check_audit_artifact_consistency.py
    --example-validation-harness                           → PASS

v3.7.1 lint suite full sweep: 6 lints exit 0; 113 pytest PASS
(58 v3.6.7 + 20 v3.6.8 SHA gate + 24 v3.6.8 trust schema + 11 new).

Roadmap: implements Step 2 of v3.7.1 impl PR sequence (PR #76 Step 0+1
shipped 2026-05-08; #77 SHA gate v2 hardening parked).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(v3.7.1 Step 2): render Section 0 in audit_snapshot.render_prompt (codex round-1 P1)

Codex round-1 review of PR #78 surfaced a P1 architectural finding: the
static Section 0 Scope Report block was prepended to the audit template
and lint-enforced, but `scripts/audit_snapshot.py::render_prompt` extracted
only sections [3, 6, 7] from the template. Section 0 was therefore absent
from every wrapper-dispatched audit prompt, defeating the spec §3.2
contract that the Scope Report rides verbatim every round.

Two changes close the gap:

1. render_prompt now extracts Section 0 from the template alongside
   Sections 3 / 6 / 7 and emits it ahead of the rebuilt Section 1 Round
   metadata block. Section 0 carries `<N_total>`, `<N_with_source>`, etc.
   placeholders that codex is expected to fill from bundle inventory at
   audit time, matching the spec §3.2 contract.

2. _extract_template_sections is now fence-aware: ## headings inside
   fenced code blocks (```/~~~) no longer terminate section extraction.
   Without this, the canonical "## Codex Audit Round N — Scope Report"
   header inside Section 0's fenced sub-block was treated as the next
   H2 boundary and Section 0 was truncated mid-fence.

Adds scripts/test_audit_snapshot_render_section_0.py with 5 render-side
regression tests pinning the contract:
  - rendered prompt contains Section 0 H2 anchor
  - rendered prompt contains canonical "## Codex Audit Round N — Scope
    Report" header literal
  - Section 0 appears strictly before Section 1 (spec line 146)
  - all four required content fields present (spec lines 136-140)
  - all three aggregate-status splits present (spec lines 147-150)

Verification:
- 6/6 lints exit 0 (v3.6.7 + audit-artifact-harness + 4 v3.6.8 lints)
- 802/802 pytest PASS across the full repo (skipping Linux-only e2e)
- scripts/test_audit_snapshot_render_section_0.py 5/5 PASS

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(v3.7.1 Step 2): scope R2/R3 to Section 0 + harden --target path (codex round-2 P2)

Codex round-2 review of PR #78 surfaced two P2 correctness issues:

P2-1: R2 (required content fields) and R3 (aggregate-status splits)
checks scanned the entire template via `in text_full`. A required marker
appearing in a later appendix or documentation block could falsely
satisfy the contract while Section 0 itself was incomplete. The audit
prompt sent to codex contains Section 0, not the appendix, so the
runtime contract was unenforced.

Fix: introduce `_section_0_block(text_full, text_no_fences)` helper that
slices from the Section 0 H2 anchor up to (but not including) the next
top-level H2 boundary. Boundary detection runs on the fence-stripped
string so a fenced sub-block inside Section 0 (the canonical Scope
Report header) does not terminate the slice. Both R2 and R3 now check
membership in `section_0_only` instead of `text_full`.

P2-2: `target.relative_to(REPO_ROOT)` raised ValueError when --target
was passed a relative repo path (e.g. `shared/templates/codex_audit_
multifile_template.md`) or an absolute path outside the repo (e.g. a
pytest tmp_path fixture). Crash signature: traceback in stderr instead
of a deterministic exit code.

Fix: introduce `_format_target_for_report(target)` helper that resolves
to absolute first, then relativizes to REPO_ROOT, falling back to the
raw resolved path if not under the repo. The lint produces a clean
report line for any --target shape.

Adds 9 round-2 mutation tests (T9-T12) to `test_check_v3_6_8_audit_
scope_block.py`:
- T9 (4 parametrized): each required field dropped from Section 0 with
  an appendix decoy carrying the same marker — lint must FAIL
- T10 (3 parametrized): each aggregate-status split dropped from
  Section 0 with appendix decoy — lint must FAIL
- T11: --target with relative repo path must not crash
- T12: --target with absolute path outside repo (pytest tmp_path) must
  not crash, lint reports a deterministic exit code

Verification:
- 7/7 lints exit 0 (including new --target relative-path invocation)
- 811/811 pytest PASS across the full repo (skipping Linux-only e2e)
- scripts/test_check_v3_6_8_audit_scope_block.py 20/20 PASS (11 round-1
  + 9 round-2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(v3.7.1 Step 2): close decoy-bypass family in lint anchors (codex round-3 P2)

Codex round-3 review of PR #78 surfaced two P2 findings in the same
decoy-bypass family — the lint accepted heading literals appearing
outside the live top-level template structure as if they were the
canonical anchors:

P2-3: `_find_section_1_position()` ran `text_full.find(SECTION_1_
HEADING_EXACT)`, so the exact Section 1 heading text appearing inside
a fenced code block (e.g. a worked example) was accepted as the anchor.
That would let a contributor rename the real top-level Section 1
heading while a fenced decoy still satisfied R5 byte-equivalence and
the Section 0/Section 1 ordering check.

P2-4: R1 SCOPE_REPORT_HEADER check ran `text_full.find()`, so the
canonical "## Codex Audit Round N — Scope Report" line appearing in
preamble documentation (or any pre-Section-0 reference) was accepted
even when Section 0 itself was missing the header. The lint scoped
the field-presence checks (R2/R3) to Section 0 in round 2 but left
this last whole-file find() unscoped.

Fixes:

1. `_find_section_1_position(text_no_fences)` now takes the fence-
   stripped string so a fenced occurrence cannot match. Caller updated
   to pass `text_no_fences` instead of `text_full`. Line-start anchoring
   preserved.

2. R1 Scope Report header check now looks inside the extracted
   `section_0_only` block instead of `text_full`. Mirrors the R2/R3
   scoping fix from round 2 — ordering check via section_0 H2 anchor
   was already correct; only the canonical header literal check was
   still whole-file.

Adds 2 round-3 mutation tests:
- T13: Section 1 byte-equivalence sentinel rejects fenced-block decoy
       (real heading title-case mutated, decoy fence carries original
       bytes — lint must FAIL)
- T14: R1 header check rejects preamble decoy (canonical header
       redacted in Section 0, decoy "Appendix" carries original bytes
       in preamble — lint must FAIL)

Verification:
- 6/6 lints exit 0
- 813/813 pytest PASS across the full repo
- scripts/test_check_v3_6_8_audit_scope_block.py 22/22 PASS (11 round-1
  + 9 round-2 + 2 round-3)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(v3.7.1 Step 2): R4 must scan fenced prompt content (codex round-4 P2)

Codex round-4 review of PR #78 surfaced one P2 in the same decoy-bypass
family closed in rounds 2-3:

R4 forbidden combined-aggregate "PASSED" verb scan ran against
text_no_fences. Fenced code blocks were blanked by the fence-mask helper
so a `verdict: PASSED` line injected inside a fenced sub-block (e.g.
the canonical Scope Report fence) was missed. But fenced blocks ARE the
prompt content codex sees verbatim — the canonical "## Codex Audit
Round N — Scope Report" header lives inside a fence. The fence-mask was
correct for boundary detection (so headings inside fences don't
terminate sections) but wrong for content scanning.

Fix: R4 now runs FORBIDDEN_AGGREGATE_PATTERNS against text_full instead
of text_no_fences. The two patterns are both verdict-shaped
(`audit summary ... PASSED` within 80 chars, or
`^... verdict: PASSED`), so the self-explanatory spec-template prose
"The combined-aggregate 'PASSED' verb is forbidden in the audit summary"
still does not match (the 80-char window between "audit summary" and
any subsequent PASSED token does not close).

Adds T15 mutation test:
- Fenced-block decoy carrying `verdict: PASSED` injected after the
  canonical Scope Report header — lint must FAIL.

Verification:
- 6/6 lints exit 0
- 814/814 pytest PASS across the full repo
- scripts/test_check_v3_6_8_audit_scope_block.py 23/23 PASS (11 round-1
  + 9 round-2 + 2 round-3 + 1 round-4)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(v3.7.1 Step 2): broaden verdict patterns + wire render-side CI (codex round-5 P2)

Codex round-5 review of PR #78 surfaced three P2 findings spanning two
families:

VERDICT-KEY BREADTH (P2-5, P2-6):

P2-5 — R4 forbidden combined-aggregate "PASSED" verb only matched
`verdict: PASSED` lines. A summary written as `Overall status: PASSED`,
`Result: PASSED`, or `Final: PASSED` slipped past the lint even though
spec line 152 forbids the verb regardless of which key precedes it.

P2-6 — R1 ordering check (Scope Report must precede any pass/fail
summary, spec line 146) only detected `## ... Summary` heading framing
or the literal `verified-against-source: PASS|FAIL` line. A bare
`verdict: PASS` or `result: FAIL` line ahead of Section 0 evaded the
check despite still being a pass/fail summary by the spec definition.

Fixes:
- Introduce shared `_VERDICT_KEY` regex covering verdict/status/result/
  final/final-status/overall/overall-status. R4
  FORBIDDEN_AGGREGATE_PATTERNS line-anchored verdict pattern now uses
  `_VERDICT_KEY` instead of hard-coded `verdict`.
- New BARE_VERDICT_BEFORE_SECTION_0_PATTERNS list driving the R1
  ordering check: heading-style, the legacy first-aggregate-split
  signal, plus a bare `<key>: PASS|FAIL|PASSED|FAILED` line. The
  ordering scan still runs on text_no_fences so a verdict-shaped line
  inside fenced documentation does NOT trip the rule.

CI HOOKUP GAP (P2-7):

P2-7 — `scripts/test_audit_snapshot_render_section_0.py` (the round-1
P1 closure: render-side regression that proves the wrapper-dispatched
audit prompt actually contains Section 0) was never invoked by
`.github/workflows/spec-consistency.yml`. Lint covered the static
template; the runtime path that builds the codex prompt was unguarded
by CI. A future render_prompt regression that drops Section 0 again
would pass spec-consistency silently — exactly the failure mode
round-1 P1 was added to prevent.

Fix: add a dedicated `Run v3.7.1 audit_snapshot render-side Section 0
regression tests` workflow step that runs
`pytest scripts/test_audit_snapshot_render_section_0.py -v`.

Adds 7 round-5 mutation tests:
- T16 (3 parametrized): non-`verdict` key + PASSED → FAIL
- T17 (4 parametrized): bare `<key>: PASS|FAIL` line before Section 0 → FAIL

Verification:
- 6/6 lints exit 0
- 821/821 pytest PASS across the full repo
- scripts/test_check_v3_6_8_audit_scope_block.py 30/30 PASS (11 round-1
  + 9 round-2 + 2 round-3 + 1 round-4 + 7 round-5)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(v3.7.1 Step 2): line-anchor header check + multi-word verdict keys (codex round-6 P2)

Codex round-6 review of PR #78 surfaced two more P2 lint-bypass paths:

P2-8 — R1 SCOPE_REPORT_HEADER check used `in section_0_only` substring
match. A prose mention inside Section 0 like
`The required header is "## Codex Audit Round N — Scope Report"` could
satisfy R1 even when the real heading line had been removed or renamed.
The rendered audit prompt needs the actual heading line, not a
documentation reference.

Fix: replace the substring check with a line-anchored regex
(`^##\s+Codex Audit Round N\s+—\s+Scope Report\b`, MULTILINE) so only
a real heading line at start-of-line satisfies R1. Prose mentions are
no longer accepted.

P2-9 — `_VERDICT_KEY` accepted only single-word verdict tokens (with
the explicit `final[\s_-]?status` and `overall(?:[\s_-]?status)?` pair).
Common multi-word keys like `Final verdict: PASSED`, `Audit status:
PASSED`, `Overall verdict: PASSED` slipped past R4 and the bare-verdict
ordering check.

Fix: rewrite `_VERDICT_KEY` to allow 0–2 short alphabetic qualifier
words ahead of a canonical verdict token (verdict / status / result /
final / outcome / overall):
`(?:[A-Za-z]{1,15}\s+){0,2}<token>`. This catches the multi-word
combinations codex enumerated without false-positives on spec self-
explanation prose like `The combined-aggregate "PASSED" verb is
forbidden in the audit summary` (verified by smoke test on the
spec-template prose lines).

Adds 6 round-6 mutation tests:
- T18: prose mention of header literal inside Section 0 (real heading
  removed) → FAIL
- T19 (5 parametrized): multi-word verdict keys + PASSED →
  FAIL — `Final verdict`, `Audit status`, `Overall verdict`,
  `Final status`, `Final Result`

Verification:
- 6/6 lints exit 0
- 827/827 pytest PASS across the full repo
- scripts/test_check_v3_6_8_audit_scope_block.py 36/36 PASS (11 round-1
  + 9 round-2 + 2 round-3 + 1 round-4 + 7 round-5 + 6 round-6)
- Smoke-tested 5 spec self-explanation prose lines: all OK
  (no false-positive on `audit summary`, `combined-aggregate`,
  `aggregate verdict ... split`, etc.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(v3.7.1 Step 2): catch multi-line audit summaries with PASSED (codex round-7 P2)

Codex round-7 review of PR #78 surfaced one P2 in the multi-line summary
shape:

P2 — R4 patterns were all same-line: `audit summary[^\n]{0,80}` and
`^\s*<key>: PASSED`. A common Markdown shape evades both:

    ## Audit Summary

    This audit PASSED.

The H2 heading and the PASSED token live on different lines, separated
by blank lines. Same gap for `## Audit Summary\n\nPASSED` (bare verdict
token).

Fix: add a third FORBIDDEN_AGGREGATE_PATTERNS entry that matches an
H2 heading whose title carries any verdict-key token, followed within
~400 chars (≈5 lines) by a `\bPASSED\b` token. The 400-char window is
tight enough that an unrelated PASSED in a much later section does not
falsely pair with the heading.

To support this:
- `_VERDICT_TOKEN` extends to include `summary` so heading like
  `## Audit Summary` is recognized as a verdict heading.
- New `_SUMMARY_HEADING` regex (built on `_VERDICT_TOKEN`) matches H2
  lines whose title carries any verdict-key word in the first 4 tokens.

Adds 4 round-7 mutation tests:
- T20 (4 parametrized): `## Audit Summary\n\nThis audit PASSED.`,
  `## Audit Summary\n\nPASSED`, `## Final Verdict\n\nThis run PASSED
  comprehensively.`, `## Audit Summary\n\nThe audit PASSED for all
  dimensions.` — all FAIL.

Verification:
- 6/6 lints exit 0
- 831/831 pytest PASS across the full repo
- scripts/test_check_v3_6_8_audit_scope_block.py 40/40 PASS (11 round-1
  + 9 round-2 + 2 round-3 + 1 round-4 + 7 round-5 + 6 round-6 + 4 round-7)
- Smoke test on baseline template: zero false-positives across all
  three FORBIDDEN_AGGREGATE_PATTERNS entries (including the new
  multi-line lookahead) — `## Codex Audit Round N — Scope Report`
  inside Section 0 does not match because no PASSED token appears
  within 400 chars; spec self-explanation prose like
  `combined-aggregate "PASSED" verb is forbidden in the audit summary`
  does not match because no preceding H2 verdict heading frames it.

Convergence note: rounds 5-7 progressively broadened R4 from a hard-
coded `verdict:` key to the multi-word/multi-line aggregate-PASSED
detection family. If round 8 returns yet another lexical bypass in the
same family, the next move is an architectural cap rule (deny any
`PASSED` in the post-Section-0 surface, allow only spec self-
explanation contexts) rather than continued pattern enumeration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(v3.7.1 Step 2): Section 1 sentinel must match whole line (codex round-8 P2)

Codex round-8 review of PR #78 surfaced one P2 in the Section 1 byte-
equivalence sentinel:

P2 — `_find_section_1_position()` used `text.find(SECTION_1_HEADING_
EXACT)` plus a line-start guard. That matched the canonical bytes as
a PREFIX of the heading line, so a mutation that appended text — e.g.
`## Section 1 — Round metadata (renamed)`, `## Section 1 — Round
metadata v2`, `## Section 1 — Round metadata: revised` — still anchored
the sentinel even though the actual heading title had changed. The
rendered audit prompt would carry a different Section 1 framing but
R5 would not detect the mutation.

Fix: replace the `find()` + boundary check with a line-anchored regex
that requires the canonical heading bytes to occupy the whole line:

    ^## Section 1 — Round metadata[ \t]*(?:\n|\Z)

Optional trailing whitespace before the newline is permitted (since
that is invisible in rendered Markdown), but anything else after the
canonical bytes — including a space + parenthesized qualifier — fails.
\Z handles the case where the heading is the very last line of the
file.

Adds 4 round-8 mutation tests:
- T21 (4 parametrized): Section 1 heading with appended text — `(renamed)`,
  ` v2`, `: revised`, ` — Round Metadata Override` — all FAIL.

Verification:
- 6/6 lints exit 0
- 835/835 pytest PASS across the full repo
- scripts/test_check_v3_6_8_audit_scope_block.py 44/44 PASS (11 round-1
  + 9 round-2 + 2 round-3 + 1 round-4 + 7 round-5 + 6 round-6 + 4 round-7
  + 4 round-8)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(v3.7.1 Step 2): Affected-refcodes required + scope ordering to Scope Report content (codex round-9 P2)

Codex round-9 review of PR #78 surfaced two P2 holes in the Scope Report
contract enforcement:

P2a — REQUIRED_FIELDS was missing the spec line 142 entry
`**Affected refcodes (description-only):**`. The lint accepted Scope
Reports that disclosed the count of unverified entries but hid which
refcodes were actually unaudited, defeating the disclosure purpose.

Fix: extend REQUIRED_FIELDS to five entries (lines 136-142). Mirror
test fixture REQUIRED_FIELDS list also extends so T8 parametrization
covers the new field.

P2b — R1 ordering check ("no pass/fail summary ahead of Scope Report")
scoped its scan to text BEFORE the Section 0 H2 anchor only. A verdict
line inserted INSIDE Section 0 — between the H2 and the canonical
fenced `## Codex Audit Round N — Scope Report` header — slipped past:
the H2 alone is not the Scope Report content. Spec line 146 requires
the Scope Report CONTENT (the disclosure block) to precede any
pass/fail summary, not just the heading wrapper.

Fix: re-anchor the ordering boundary to the canonical Scope Report
header line (or, if absent, fall back to Section 1, then Section 0 H2).
The boundary now reflects where Scope Report content actually starts,
not where its label appears. Scan still uses text_no_fences so verdict-
shaped lines inside fenced documentation references do not trip.

Adds round-9 mutation tests:
- T22: Affected-refcodes field removed from Section 0 → FAIL
- T23 (3 parametrized): verdict line between Section 0 H2 and canonical
  header → FAIL — `verdict: PASS`, `result: FAIL`, `Final status:
  PASSED` (last one would already FAIL via R4 forbidden-verb scan, but
  exercises ordering path explicitly)

Verification:
- 6/6 lints exit 0
- 841/841 pytest PASS across the full repo
- scripts/test_check_v3_6_8_audit_scope_block.py 50/50 PASS (11 round-1
  + 9 round-2 + 2 round-3 + 1 round-4 + 7 round-5 + 6 round-6 + 4 round-7
  + 4 round-8 + 4 round-9 + 2 newly-covered T8 parametrize: the new
  Affected-refcodes field exercises the existing T8 mutation harness)
- Baseline template still PASSES — line 142 already carries the
  Affected-refcodes field inside the canonical fenced Scope Report
  block, so the new required-field check is satisfied.

Convergence note: rounds 5-7 broadened R4 forbidden-verb detection;
round 8 hardened R5 sentinel; round 9 closed two distinct contract
gaps. Different family per round still — no "补 counter" pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(v3.7.1 Step 2): ordering scan must include live Scope Report fence (codex round-10 P2)

Codex round-10 review of PR #78 surfaced a regression introduced by the
round-9 ordering boundary fix:

P2 — Round-9 anchored the R1 ordering scan on the canonical Scope
Report header, looked up via `text_no_fences`. But the canonical
header (`## Codex Audit Round N — Scope Report`) lives INSIDE the
Section 0 fenced block by spec design, so a fence-stripped search
could never find it. The boundary then fell back to Section 1, and
because the prefix was also `text_no_fences`, every verdict line
inserted INSIDE the live Scope Report fence (e.g. between the opening
``` and the canonical header) was masked. The lint reported PASS
despite a clear ordering violation in the rendered prompt content.

Fix: search `text_full` for both anchor discovery AND the prefix scan.
The earlier round-5 motivation for using fence-masked text was to keep
documentation references in late-template appendices from tripping the
rule — but the prefix is now bounded by the canonical header, which
sits near the top of Section 0, so appendix references (post-Section
7) fall well outside the scan range and cannot interfere. Meanwhile
the in-fence verdict that broke round-10 is captured.

Adds round-10 mutation tests:
- T24 (3 parametrized): verdict line inside fenced Scope Report block
  but BEFORE canonical header — `verdict: PASS`, `result: FAIL`,
  `Final status: PASSED`. The third would already FAIL via R4
  forbidden-verb scan but exercises the ordering path explicitly; the
  first two now correctly FAIL via R1 ordering.

Verification:
- 6/6 lints exit 0
- 844/844 pytest PASS across the full repo
- scripts/test_check_v3_6_8_audit_scope_block.py 53/53 PASS (cumulative
  across rounds 1-10)
- Baseline template still PASSES — no verdict line inside the live
  fence ahead of the canonical header.

Convergence note: round-10 closed a regression introduced by round-9.
That is a different convergence shape from "补 counter" — the round-9
fix had a real subtle bug (fence-strip + canonical-header-in-fence
interaction) that took an additional review pass to surface. Rounds
1-10 cumulative: 1 P1 + 17 P2 closed; no P0; cross-cutting families
covered (render-side parity / scope-to-Section-0 / decoy-bypass /
fence-mask asymmetry / verdict-key breadth / multi-line summary /
prefix-match sentinel / contract field completeness / fence ordering).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(v3.7.1 Step 2): boundary derives from Section-0 validated header (codex round-11 P2)

Codex round-11 review of PR #78 surfaced a corner-case false-negative
in the round-10 ordering boundary derivation:

P2 — Round-10 located the canonical Scope Report header via
`re.search(... text_full)` — first whole-file occurrence. A preamble
decoy line carrying the same header text (e.g. inside a documentation
block or appendix moved to the top) would short-circuit
`ordering_boundary` to the decoy's offset. Any `verdict: PASS` or
similar pass/fail summary inserted BETWEEN the decoy and the real
Scope Report block would then sit OUTSIDE the prefix scan range and
the lint would not flag it, despite a clear ordering rule violation
in the rendered prompt.

Fix: derive `ordering_boundary` from the Section-0-validated header
position. Search for the canonical header inside the already-extracted
`section_0_only` block (R1 already enforces it must live there as a
real heading line), then map back to absolute offset by adding the
Section 0 start. A preamble decoy that lives outside Section 0 cannot
short-circuit the boundary because the search is scoped to the
section_0_only slice. Fallbacks to Section 1 / Section 0 H2 retained
as coarse upper bounds when the validated header itself is missing
(those cases are already separately failed by R1).

Adds round-11 mutation test:
- T25: preamble decoy `## Codex Audit Round N — Scope Report` line +
  `## Documentation reference` heading + verdict line BETWEEN decoy
  and real Section 0 → FAIL.

Verification:
- 6/6 lints exit 0
- 845/845 pytest PASS across the full repo
- scripts/test_check_v3_6_8_audit_scope_block.py 54/54 PASS (cumulative
  rounds 1-11)
- Baseline template still PASSES — no preamble decoy of the canonical
  header exists in the live template.

Convergence note: rounds 9-11 form an "anchor-derivation" cluster —
each round refined how the ordering boundary is computed. Round 9
fixed scoping (header anchor instead of Section 0 H2 as boundary).
Round 10 fixed search domain (text_full instead of text_no_fences).
Round 11 fixed search scope (section_0_only instead of whole text).
This reflects a real difficulty: the boundary needs to be
authoritative (validated, not first occurrence), live (not fence-
stripped), and unique (anchored within the validated Section 0).
That is the genuine architectural shape, not whack-a-mole.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(v3.7.1 Step 2): Scope Report header must be line-equal not prefix (codex round-12 P2)

Codex round-12 review of PR #78 surfaced a P2 symmetric to round-8:

P2 — R1 line-anchored regex
`^##\s+Codex Audit Round N\s+—\s+Scope Report\b` accepted suffixed
forms because `\b` matches after `Report`. A header line mutated to
`## Codex Audit Round N — Scope Report (draft)` or `... — Scope Report
v2` still satisfied R1 even though the rendered prompt would carry a
non-canonical heading title.

Fix: introduce a module-level `_SCOPE_REPORT_HEADER_LINE_RE` requiring
line equality:

    ^## Codex Audit Round N — Scope Report[ \t]*(?:\n|\Z)

Optional trailing whitespace before the line break is tolerated
(invisible in rendered Markdown); anything else after the canonical
bytes — including a space + parenthesized suffix — fails. \Z handles
the EOF case. Both R1 (header presence inside Section 0) and the
ordering boundary derivation (round-11 P2 closure) now reuse this
single regex, so the line-equality contract holds across both checks.

This is the structural mirror of round-8's _find_section_1_position
fix (Section 1 byte-equivalence sentinel needed the same anchoring).
The two heading sentinels now share the same line-equality discipline.

Adds round-12 mutation test:
- T26 (4 parametrized): Scope Report header with suffix —
  ` (draft)`, ` v2`, `: revised`, ` — Updated` — all FAIL.

Verification:
- 6/6 lints exit 0
- 849/849 pytest PASS across the full repo
- scripts/test_check_v3_6_8_audit_scope_block.py 58/58 PASS (cumulative
  rounds 1-12)
- Baseline template still PASSES — canonical header line carries no
  trailing characters before the newline.

Convergence note: rounds 8 + 12 form a "line-equality" cluster (Section
1 sentinel; Scope Report header). Both used `find()` or `\b`-anchored
regex that accepted prefix matches. The fix is identical in shape:
require the canonical bytes to occupy the whole line, with optional
trailing whitespace. Now consistently applied across both heading
sentinels via a shared module-level regex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(v3.7.1 Step 2): R4 architectural cap rule for unquoted PASSED (codex round-13 P2)

Codex round-13 review of PR #78 surfaced one P2 in the R4 forbidden-
verb family — but this round triggers an architectural inflection
per `feedback_architectural_inflection_after_repeated_p1.md`:

P2 — Bare aggregate `PASSED` in output instructions, e.g.
`If zero findings, output exactly: PASSED`, evaded all three legacy
FORBIDDEN_AGGREGATE_PATTERNS because none required `audit summary`
phrasing, a `<key>:` line, or an H2 summary heading.

This is the FIFTH round of the same R4 family (rounds 4, 5, 6, 7, 13).
Each round added a new lexical pattern; each round codex returned
with another shape. Per `feedback_codex_review_vs_resume_audit_scope.md`
("补 counter, 不改 rule" stop condition), pattern enumeration is
unbounded — an attacker (or future contributor) can always coin a
new sentence carrying `PASSED` that no enumerated regex foresaw.

ARCHITECTURAL FIX: replace lexical enumeration with a CAP RULE.

The new R4 scans the post-Section-0 surface for any UNQUOTED `\bPASSED\b`
token. Quoted forms remain permitted because the canonical spec
template uses them in self-explanation prose:
  - line 26: `"PASSED" verdict cannot mask un-retrieved sources`
  - line 49: `combined-aggregate "PASSED" verb is forbidden`
Allowed quote characters: straight double `"PASSED"`, single `'PASSED'`,
backtick `` `PASSED` `` (Markdown inline code), and CJK/curly
"PASSED" (defensive). Any other context (instruction phrasing,
verdict assertion, output specification, prose claim) fails the cap.

Surface boundary: from the Section 0 H2 anchor onward — the part of
the template that becomes the rendered audit prompt body. Anything
ahead of Section 0 is upstream meta-prose (template structure
description) and is checked separately by the legacy patterns
(retained as supplementary diagnostics so structural pre-Section-0
violations still surface).

This is "boil the lake" applied to lexical enforcement: instead of
enumerating allowed/forbidden patterns, deny by default and allow
only the explicit self-explanation form. Future contributors who
need to discuss `PASSED` in prose use the quoted form (which is
what the spec template already does).

Adds round-13 mutation tests:
- T27 (4 parametrized): bare PASSED in instruction phrasings —
  `If zero findings, output exactly: PASSED`, `On success: PASSED`,
  `Mark the run PASSED in the manifest.`,
  `Emit a single token: PASSED.` — all FAIL.
- T28 (3 parametrized): quoted self-explanation forms must remain
  ALLOWED — straight double quotes, backtick inline code, embedded
  documentation note — all PASS.

Verification:
- 6/6 lints exit 0
- 856/856 pytest PASS across the full repo
- scripts/test_check_v3_6_8_audit_scope_block.py 65/65 PASS (cumulative
  rounds 1-13 plus the cap-rule allow tests)
- Baseline template PASSES — both existing `"PASSED"` mentions
  (lines 26, 49) are quoted self-explanation form, allowed by cap rule.

Convergence note: this closes the R4 lexical-enumeration cluster
that ran from round 4 to round 13. Future rounds may surface non-R4
families (more decoy / boundary / contract-completeness shapes), but
cannot return to the same family — the cap rule subsumes all
permutations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:00:43 +08:00

465 lines
21 KiB
Python

#!/usr/bin/env python3
"""ARS v3.7.1 Step 2 — D2 audit Scope Report block lint.
Spec: docs/design/2026-04-30-ars-v3.6.8-trust-provenance-and-drift-transparency-spec.md
§3.2 (D2 — Audit scope coverage non-disclosure)
§4 Step 2 — Audit report Scope Report block (lines 412-425)
Enforces, on the codex audit prompt template:
R1. A Section 0 Scope Report header appears strictly BEFORE the first
Section 1 heading (spec line 146: "must appear before any pass/fail
summary"). The header is the literal "## Codex Audit Round N — Scope
Report" (spec line 134).
R2. The Scope Report block carries the four mandatory content fields
(spec lines 136-140):
- Total entries audited
- Entries with retrieved original source
- Entries description-only (no retrieved source)
- Audit scope warning
R3. The aggregate-status section carries the three required splits
(spec lines 147-150):
- verified-against-source: PASS | FAIL
- description-internally-consistent: PASS | FAIL
- unaudited-due-to-missing-source: <count>
R4. The combined-aggregate "PASSED" verb is forbidden in audit summary
contexts (spec line 152). We forbid the literal pattern
"Audit summary ... PASSED" (case-insensitive on the surrounding
tokens) anywhere in the template.
R5. Additive-prepend invariant per Q5 amend (spec §3.2 line 131,
§3.7 line 346): the Section 1 heading must appear with exact bytes
"## Section 1 — Round metadata" so prepending Section 0 has not
altered the existing v3.6.7 audit template's byte-equivalent
Sections 1-7 zone.
Attack-surface design notes (informed by issue #77 lessons):
- Heading-anchored matching uses begin-of-line regex `^## ` to skip
headings that appear inside fenced code blocks. The Scope Report
block in the canonical template DOES contain a fenced sub-block with
its own "## Codex Audit Round N — Scope Report" heading; our header
detector must recognize that as the canonical Scope Report marker
even though it lives inside a fence. We resolve this by allowing the
Scope Report header to be inside the first fenced block of Section 0
OR at top-level — but require that AT LEAST ONE such header anchor
exists strictly before the Section 1 boundary, and that exactly one
Section-0 anchor exists at top level (the H2 outside any fence).
- Multiple Section-0 H2 anchors at top level → reject (mirror of #77
P1-1 duplicate-marker bypass).
Exit codes: 0 on PASS, 1 on FAIL.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_TARGET = REPO_ROOT / "shared" / "templates" / "codex_audit_multifile_template.md"
# The canonical Section-0 H2 anchor at the top level of the template.
SECTION_0_H2 = "## Section 0"
# The Scope Report header literal, placed inside the fenced markdown block
# of Section 0 per spec line 134.
SCOPE_REPORT_HEADER = "## Codex Audit Round N — Scope Report"
# The byte-equivalent Section 1 heading per Q5 invariant.
SECTION_1_HEADING_EXACT = "## Section 1 — Round metadata"
# Required Scope Report content fields (spec lines 136-142).
# Codex round-9 P2a: spec line 142 requires the Affected-refcodes
# disclosure too — without it, an audit can hide which entries were
# unaudited even if the count is published.
REQUIRED_FIELDS: list[str] = [
"**Total entries audited:**",
"**Entries with retrieved original source:**",
"**Entries description-only (no retrieved source):**",
"**Audit scope warning:**",
"**Affected refcodes (description-only):**",
]
# Required aggregate-status splits (spec lines 147-150).
REQUIRED_SPLITS: list[str] = [
"verified-against-source",
"description-internally-consistent",
"unaudited-due-to-missing-source",
]
# Line-equality regex for the canonical Scope Report header. End-of-line
# anchored so a suffixed form like "## Codex Audit Round N — Scope Report
# (draft)" cannot satisfy R1 by prefix match (codex round-12 P2,
# symmetric to round-8 P2 on Section 1). Optional trailing whitespace
# before the line break is tolerated (invisible in rendered Markdown);
# anything else after the canonical bytes — including a space + suffix —
# fails. \Z covers EOF cases.
_SCOPE_REPORT_HEADER_LINE_RE = re.compile(
rf"^{re.escape(SCOPE_REPORT_HEADER)}[ \t]*(?:\n|\Z)",
re.MULTILINE,
)
# Forbidden combined-aggregate "PASSED" verb in audit summary contexts.
# Spec line 152: "The combined-aggregate 'PASSED' verb is forbidden in
# audit summary." Codex round-4 forced fence-content scan; round-5 P2-5
# broadened the verdict-key set (verdict / status / result / final /
# overall) so the lint catches `Overall status: PASSED` etc., not only
# `verdict: PASSED`. PASSED is matched case-insensitive on the surrounding
# tokens but the verb itself stays caps-only — that IS the forbidden
# verb form. The 80-char window after `audit summary` keeps the spec
# self-explanation prose (`...forbidden in the audit summary.`) clear
# of any PASSED token within reach.
# A verdict-shaped key word: any of the canonical verdict tokens, optionally
# preceded by one or two short qualifier words (e.g. "Final verdict",
# "Audit status", "Overall final result"). Codex round-6 P2-9 broadened
# this so multi-word keys like `Final verdict: PASSED` are also caught.
# Each qualifier is restricted to alphabetic characters of length 1-15
# so the regex does not gobble unrelated text on the same line.
_VERDICT_TOKEN = r"(?:verdict|status|result|final|outcome|overall|summary)"
_VERDICT_KEY = rf"(?:[A-Za-z]{{1,15}}\s+){{0,2}}{_VERDICT_TOKEN}"
# Heading-style summary token used for multi-line lookahead detection
# (codex round-7 P2). Catches `## Audit Summary`, `## Final Verdict`,
# `## Overall Outcome`, etc. — H2 headings whose title carries any
# verdict-key token (bare PASSED on the next lines is the violation).
_SUMMARY_HEADING = rf"^##\s+(?:[A-Za-z][\w-]*\s+){{0,3}}{_VERDICT_TOKEN}\b[^\n]*"
FORBIDDEN_AGGREGATE_PATTERNS: list[re.Pattern[str]] = [
# Same-line: "audit summary ... PASSED" within 80 chars (legacy R4).
re.compile(r"audit\s+summary[^\n]{0,80}\bPASSED\b", re.IGNORECASE),
# Same-line: `<key>: PASSED` (legacy + multi-word key).
re.compile(rf"^\s*{_VERDICT_KEY}\s*:\s*PASSED\b", re.IGNORECASE | re.MULTILINE),
# Multi-line (codex round-7 P2): `## Summary heading` followed within
# ~5 lines (≤400 chars across newlines) by a `PASSED` token. Catches
# `## Audit Summary\n\nThis audit PASSED.` and `## Audit Summary\n\nPASSED`.
# DOTALL lets `.` cross newlines; the 400-char window stays tight enough
# that a much later unrelated PASSED in a separate section does not pair
# with the heading.
re.compile(
rf"{_SUMMARY_HEADING}.{{0,400}}?\bPASSED\b",
re.IGNORECASE | re.MULTILINE | re.DOTALL,
),
]
# Bare-verdict line patterns used to detect a pass/fail summary preceding
# Section 0 (spec line 146 firm rule). Wider than R4 because here we want
# to catch BOTH PASS and FAIL — anything resembling a verdict line counts
# as a pass/fail summary, regardless of the verb.
BARE_VERDICT_BEFORE_SECTION_0_PATTERNS: list[re.Pattern[str]] = [
# Heading-style pass/fail summary
re.compile(r"^##\s+[A-Za-z][^\n]{0,80}\b(Summary|Verdict)\b", re.MULTILINE),
# Existing first-aggregate-split line (kept from earlier rounds)
re.compile(r"^\s*verified-against-source\s*:\s*(PASS|FAIL)\b", re.MULTILINE),
# Bare `<key>: PASS|FAIL` line, no heading framing
re.compile(rf"^\s*{_VERDICT_KEY}\s*:\s*(PASS|FAIL|PASSED|FAILED)\b", re.IGNORECASE | re.MULTILINE),
]
def _strip_fenced_blocks(text: str) -> str:
"""Replace fenced code blocks with same-length whitespace so headings
inside fences don't count as top-level H2 anchors.
Length preservation keeps any line/column reporting consistent with
the original file. We support the standard ``` and ~~~ fences.
"""
def _replace(match: re.Match[str]) -> str:
content = match.group(0)
# Replace non-newline characters with spaces so newlines and
# column positions are preserved, but content is neutralized.
return "".join(" " if ch != "\n" else "\n" for ch in content)
pattern = re.compile(
r"^([ \t]*)(```|~~~)[^\n]*\n.*?^\1\2[ \t]*$",
re.DOTALL | re.MULTILINE,
)
return pattern.sub(_replace, text)
def _find_section_1_position(text_no_fences: str) -> int:
"""Return the byte offset of the exact top-level Section 1 heading, or -1 if absent.
Codex round-3 P2-3: a decoy heading inside a fenced code block (e.g. in
a worked example) was previously accepted as the anchor. The check now
runs against the fence-stripped text and requires line-start anchoring
so a fenced occurrence of the exact heading bytes cannot satisfy R5.
Codex round-8 P2: byte-equivalence must hold for the WHOLE heading line.
A `find()` match treats the canonical bytes as a prefix and accepts
`## Section 1 — Round metadata (renamed)` as if it were the canonical
heading. Match a full line whose only content is the canonical heading,
optionally followed by trailing whitespace before the line break.
"""
line_re = re.compile(
rf"^{re.escape(SECTION_1_HEADING_EXACT)}[ \t]*(?:\n|\Z)",
re.MULTILINE,
)
match = line_re.search(text_no_fences)
return match.start() if match else -1
def _format_target_for_report(target: Path) -> str:
"""Format target path for lint report.
Codex round-2 P2-2: when --target is a relative repo path or points
outside REPO_ROOT, `target.relative_to(REPO_ROOT)` raises ValueError.
Resolve to absolute first, then attempt relativization, falling back
to the raw path if the resolved path is not under REPO_ROOT.
"""
try:
resolved = target.resolve()
except (OSError, RuntimeError):
return str(target)
try:
return str(resolved.relative_to(REPO_ROOT))
except ValueError:
return str(resolved)
def _section_0_block(text_full: str, text_no_fences: str) -> str:
"""Slice the Section 0 block from the H2 anchor up to (but not including)
the next H2 boundary at top level. Returns "" if the anchor is missing.
Codex round-2 P2-1: required-field and aggregate-status-split checks
must scope to Section 0 only, not whole-file. Otherwise a marker that
appears in a later appendix or documentation block can falsely satisfy
the check while Section 0 itself is incomplete.
Boundary detection runs on the fence-stripped string so a fenced sub-
block inside Section 0 (e.g. the canonical Scope Report header) does
not terminate the slice. The returned slice is taken from the original
text so contents are byte-equivalent to the source.
"""
import re as _re
section_0_match = _re.search(
r"^## Section 0\b", text_no_fences, _re.MULTILINE
)
if section_0_match is None:
return ""
start = section_0_match.start()
next_h2 = _re.search(r"^## ", text_no_fences[start + 1 :], _re.MULTILINE)
end = (start + 1 + next_h2.start()) if next_h2 else len(text_full)
return text_full[start:end]
def check(target: Path) -> tuple[int, list[str]]:
"""Run all rules. Return (exit_code, report_lines)."""
if not target.exists():
return 1, [f"FAIL: target file does not exist: {target}"]
text = text_full = target.read_text(encoding="utf-8")
text_no_fences = _strip_fenced_blocks(text)
section_0_only = _section_0_block(text_full, text_no_fences)
report: list[str] = [f"[v3.7.1 audit-scope-block] target: {_format_target_for_report(target)}"]
failed: bool = False
# ---- R5: Section 1 byte-equivalence sentinel ----
# Codex round-3 P2-3: search the fence-stripped string so a decoy heading
# inside a fenced code block (e.g. worked example) cannot satisfy R5.
section_1_pos = _find_section_1_position(text_no_fences)
if section_1_pos == -1:
failed = True
report.append(
" FAIL [R5]: Section 1 heading exact bytes "
f"{SECTION_1_HEADING_EXACT!r} missing or shifted; "
"Q5 additive-prepend invariant broken (spec §3.2 line 131)."
)
# ---- R1: Section 0 H2 anchor present, before Section 1 ----
section_0_anchors = [
m.start()
for m in re.finditer(r"^## Section 0\b", text_no_fences, re.MULTILINE)
]
if not section_0_anchors:
failed = True
report.append(
" FAIL [R1]: Section 0 Scope Report header missing "
f"(expected H2 anchor starting with {SECTION_0_H2!r} at top level)."
)
elif len(section_0_anchors) > 1:
failed = True
report.append(
f" FAIL [R1]: multiple Section 0 H2 anchors found at top level "
f"(positions={section_0_anchors}); duplicate-marker hardening rejects "
f"more than one (compare issue #77 P1-1)."
)
elif section_1_pos != -1 and section_0_anchors[0] >= section_1_pos:
failed = True
report.append(
f" FAIL [R1]: Section 0 anchor (offset {section_0_anchors[0]}) "
f"appears at or after Section 1 heading (offset {section_1_pos}); "
f"spec line 146 firm rule: Scope Report must appear BEFORE any "
f"pass/fail summary."
)
# ---- R1 continued: Scope Report header literal must appear inside Section 0 ----
# Codex round-3 P2-4: scope to section_0_only so a preamble decoy
# (e.g. an "Appendix" or "historical reference" carrying the exact
# header) cannot falsely satisfy R1 when the real Section 0 block
# is missing the canonical header.
# Codex round-6 P2-8: anchor to a real heading line. A prose mention
# of the header literal (`The required header is "## Codex Audit Round
# N — Scope Report"`) inside Section 0 must NOT satisfy the contract.
# Codex round-12 P2: require line equality (not prefix) so a suffixed
# form like `... — Scope Report (draft)` cannot satisfy R1. Uses the
# module-level _SCOPE_REPORT_HEADER_LINE_RE shared with the ordering
# boundary derivation below.
if _SCOPE_REPORT_HEADER_LINE_RE.search(section_0_only) is None:
failed = True
report.append(
f" FAIL [R1]: Scope Report header line {SCOPE_REPORT_HEADER!r} "
"missing from Section 0 (spec line 134; must appear as a real "
"heading line, not a prose mention)."
)
# ---- R6 firm-rule check: no pass/fail summary ahead of Scope Report ----
# Spec line 146 ordering rule: the Scope Report content must appear
# before any pass/fail summary surface.
# Codex round-5 P2-6 broadened detection to bare-verdict lines.
# Codex round-9 P2b: anchor on the canonical Scope Report header.
# Codex round-10 P2: search includes fenced content (canonical header
# lives in fence by spec design).
# Codex round-11 P2: derive boundary from the Section-0-validated
# header, not first whole-file occurrence. A preamble decoy line
# carrying the canonical header text would otherwise short-circuit
# the boundary too early, letting verdict lines between the decoy
# and the real Scope Report content escape the prefix scan.
ordering_boundary: int | None = None
section_0_start = section_0_anchors[0] if section_0_anchors else None
if section_0_start is not None:
scope_header_in_section_0 = _SCOPE_REPORT_HEADER_LINE_RE.search(section_0_only)
if scope_header_in_section_0 is not None:
ordering_boundary = section_0_start + scope_header_in_section_0.start()
if ordering_boundary is None and section_1_pos != -1:
ordering_boundary = section_1_pos
if ordering_boundary is None and section_0_start is not None:
ordering_boundary = section_0_start
if ordering_boundary is not None:
prefix = text_full[:ordering_boundary]
for pattern in BARE_VERDICT_BEFORE_SECTION_0_PATTERNS:
match = pattern.search(prefix)
if match is not None:
failed = True
anchor = match.group(0).strip()
report.append(
f" FAIL [R1]: pass/fail summary detected ahead of Scope Report content "
f"(anchor: {anchor!r}); spec line 146 requires Scope Report "
f"to appear BEFORE any pass/fail summary."
)
break # one fail diagnostic per check is enough
# ---- R2: required content fields ----
# Codex round-2 P2-1: scope to Section 0 only. A marker in an appendix
# or documentation block must NOT satisfy the contract — the audit
# prompt that gets sent to codex contains Section 0, not the appendix.
for field in REQUIRED_FIELDS:
if field not in section_0_only:
failed = True
report.append(
f" FAIL [R2]: required Scope Report field missing from Section 0: "
f"{field!r} (spec lines 136-140)."
)
# ---- R3: aggregate-status three-way split ----
# Codex round-2 P2-1: same scoping fix as R2.
for split in REQUIRED_SPLITS:
if split not in section_0_only:
failed = True
report.append(
f" FAIL [R3]: required aggregate-status split missing from Section 0: "
f"{split!r} (spec lines 147-150)."
)
# ---- R4: forbidden combined-aggregate 'PASSED' verb (cap rule) ----
# Codex rounds 4-7 + 13 enumerated the forbidden surface lexically
# (verdict-key + same-line, multi-word keys, multi-line summary headings,
# bare verdict lines). Each round surfaced another shape codex could
# construct, signalling that pattern enumeration is unbounded.
#
# Round-13 architectural inflection (per
# `feedback_architectural_inflection_after_repeated_p1.md`): replace
# the enumeration with a cap rule. ANY unquoted `PASSED` token on the
# post-Section-0 surface violates the spec line 152 contract. Quoted
# forms (`"PASSED"`, `'PASSED'`, backtick `` `PASSED` ``) remain
# permitted because the canonical spec template uses them in self-
# explanation prose ("the combined-aggregate 'PASSED' verb is forbidden").
#
# Surface = template text after the Section 0 H2 anchor (or whole
# text if Section 0 anchor missing — already failed by R1). The
# legacy FORBIDDEN_AGGREGATE_PATTERNS still run as supplementary
# diagnostics on the pre-Section-0 surface (the upstream parts where
# an audit summary heading would be a structural violation).
cap_rule_surface_start = (
section_0_anchors[0] if section_0_anchors else 0
)
cap_rule_surface = text_full[cap_rule_surface_start:]
bare_passed_re = re.compile(r"\bPASSED\b")
for match in bare_passed_re.finditer(cap_rule_surface):
# Determine if this PASSED is quoted (allowed) or bare (forbidden).
start, end = match.start(), match.end()
# Look at the immediate surrounding character on each side. Quoted
# if both neighbours are matching quote characters or if either
# side is a backtick (Markdown inline code).
left = cap_rule_surface[start - 1] if start > 0 else ""
right = cap_rule_surface[end] if end < len(cap_rule_surface) else ""
is_quoted = (
(left == '"' and right == '"')
or (left == "'" and right == "'")
or (left == "`" and right == "`")
or (left == "" and right == "")
)
if is_quoted:
continue
failed = True
# Build a small context excerpt for the diagnostic.
ctx_start = max(0, start - 30)
ctx_end = min(len(cap_rule_surface), end + 30)
excerpt = cap_rule_surface[ctx_start:ctx_end].replace("\n", " ")
report.append(
f" FAIL [R4]: unquoted PASSED token on post-Section-0 surface "
f"(spec line 152 cap rule). Context: …{excerpt}"
"Use quoted form (\"PASSED\", `PASSED`) for self-explanation only."
)
break # one diagnostic is enough; user fixes then re-runs
# Supplementary diagnostics: run the legacy patterns on the FULL text
# so any structural violations they specifically detect (audit-summary
# heading + verdict combinations) still surface even when the cap
# rule already fired. Skip if already failed to avoid noise.
if not failed:
for pattern in FORBIDDEN_AGGREGATE_PATTERNS:
match = pattern.search(text_full)
if match is not None:
failed = True
report.append(
f" FAIL [R4]: forbidden combined-aggregate 'PASSED' verb in "
f"audit summary context: {match.group(0)!r} "
"(spec line 152: combined-aggregate 'PASSED' is forbidden)."
)
if failed:
report.append("[v3.7.1 audit-scope-block] FAILED")
return 1, report
report.append("[v3.7.1 audit-scope-block] PASS")
return 0, report
def main() -> int:
parser = argparse.ArgumentParser(
description="ARS v3.7.1 Step 2 — D2 audit Scope Report block lint."
)
parser.add_argument(
"--target",
type=Path,
default=DEFAULT_TARGET,
help="Audit prompt template path (default: shared/templates/codex_audit_multifile_template.md).",
)
args = parser.parse_args()
exit_code, report = check(args.target)
print("\n".join(report))
return exit_code
if __name__ == "__main__":
sys.exit(main())