mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
v3.7.1 Step 0+1: SHA byte-equivalence gate + D1 trust-chain frontmatter schema (#76)
* feat(v3.7.1 Step 0): inversion manifest + SHA byte-equivalence lint
Spec: docs/design/2026-04-30-ars-v3.6.8-trust-provenance-and-drift-transparency-spec.md
§ Step 0 (round-1 codex F-004 amend)
Establishes v3.7.1's own inversion manifest separate from the v3.6.7
frozen scope, plus a byte-equivalence SHA gate that asserts the v3.6.7
PATTERN PROTECTION blocks in synthesis_agent / research_architect_agent /
report_compiler_agent stay byte-identical to the v3.6.7 base commit
(derived via `git log -1 -- scripts/v3_6_7_inversion_manifest.json`).
Why: Step 3a will add a "Two-Layer Citation Emission" prompt block to
those three agents OUTSIDE their v3.6.7-tagged blocks. The SHA gate keeps
that boundary enforceable from PR-1 onward, so any accidental edit inside
the v3.6.7 block is caught at CI time.
How:
- scripts/v3_6_8_inversion_manifest.json — empty entries for now;
Step 3a will populate with two-layer-citation invariants.
- scripts/check_v3_6_8_pattern_protection.py — reuses v3.6.7 lint's
heading-based extractor for byte-equivalent block isolation; resolves
default branch via `git symbolic-ref refs/remotes/origin/HEAD` →
$GITHUB_DEFAULT_BRANCH → hard-fail; handles shallow CI clones via
`git fetch --unshallow`; computes SHA-256 with raw-bytes-minus-BOM.
- .github/workflows/spec-consistency.yml — adds `fetch-depth: 0` to
checkout (so `git log -1` and `git show <commit>:<path>` see history)
+ two new lint steps right after the v3.6.7 mutation tests.
- scripts/test_check_v3_6_8_pattern_protection.py — 12 tests pinning
the contract: happy path + intra-block mutation (FAIL) + extra-block
H2 append directly after EOF newline (PASS) + same with extra blank
line (FAIL — pins the Step 3a contributor rule) + manifest shape
validation + boundary error cases.
Verification (local):
- baseline: spec-consistency / v3.6.7 protection (12 inv) /
v3.6.7 mutation (58/58) / audit-artifact-consistency — all green.
- new: SHA gate happy-path PASS; 12/12 mutation tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(v3.7.1 Step 1): D1 frontmatter schema split + trust-chain lint
Spec: docs/design/2026-04-30-ars-v3.6.8-trust-provenance-and-drift-transparency-spec.md
§ 3.1 D1 — Frontmatter trust-strength conflation
§ Step 1 — Frontmatter schema split
Splits the previously-single `verified` field into seven orthogonal
trust-chain fields on `literature_corpus_entry.schema.json` so downstream
consumers can distinguish source acquisition, source verification against
the original artifact, and description provenance. The user-set human-read
signal stays user-owned in the §3.6 peer file (NOT entry-stored) per
round-1 codex F-005 amend; the entry schema remains
`additionalProperties: false`.
Schema (additive — backward-compatible with v3.6.4 fixtures):
- 7 new optional properties: source_acquired, source_acquisition_date,
source_acquisition_path, source_verified_against_original,
source_verification_method, description_source, description_last_audit.
- 2 new `allOf` branches enforcing spec firm rules #1 and #2:
#1: source_verified_against_original=true ⇒ source_acquired=true AND
source_verification_method ∈ {codex_audit, manual_grep, vision_check}
(round-2 R2-007 amend: 'none' enumerated but FORBIDDEN here)
#2: source_acquired=false ⇒ description_last_audit ∈ {null, "none"}
- Firm rule #3 (no literal human_read_source / human_read_at on entry)
is automatically caught by the existing `additionalProperties: false`.
Agent prompts:
- deep-research/agents/bibliography_agent.md — adds "Trust-Chain
Frontmatter Discipline (v3.7.1+)" H2 with field semantics, three firm
rules, and a refusal-on-uncertain rule (do not infer verification from
derivative-bibliography agreement; emit verified=false when in doubt).
- academic-paper/agents/literature_strategist_agent.md — same H2 framed
for the downstream consumer perspective: read-only, surface
inconsistencies rather than paper over them.
Lint + tests:
- scripts/check_v3_6_8_frontmatter_trust_schema.py — defense-in-depth
over the schema; produces friendly spec-cited error messages when
scanning passports / single entries / bare lists.
- scripts/test_check_v3_6_8_frontmatter_trust_schema.py — 20 tests:
schema self-consistency + 7-field presence + additionalProperties
preserved + positive/negative for each firm rule + payload-shape
coverage + v3.6.4 fixture regression.
- .github/workflows/spec-consistency.yml — wires lint + pytest into CI.
Verification (local):
- All 4 baselines green: spec-consistency, v3.6.7 protection (12 inv),
v3.6.7 mutation (58/58), audit-artifact-consistency.
- v3.6.4 backward compat: existing literature_corpus schema lint green;
corpus-consumer-protocol lint green; all 3 adapter fixtures pass new
trust-schema lint (no entry currently carries trust fields).
- New: 20/20 trust-schema mutation tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v3.7.1 Step 1 cascade): regenerate adapters/overview.md + escape union type pipe
CI's `sync_adapter_docs.py --check` step caught two issues from the
Step 1 schema additions:
1. The 7 new trust-chain properties on literature_corpus_entry.schema.json
needed to cascade into the auto-generated property table at
academic-pipeline/references/adapters/overview.md.
2. `description_last_audit: ["string", "null"]` was the first union-typed
property in the schema. `_short_type` rendered the union as
`null | string`, but Markdown table cells are pipe-delimited, so the
row split into extra columns. The fix escapes the separator to `\|`
so the union renders as a literal pipe inside the cell.
The `_short_type` patch is generic — any future union-typed property
will render correctly without re-escaping at the call site.
Verification: `python3 scripts/sync_adapter_docs.py --check` PASSES;
all PR-1 lints + tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v3.7.1 Step 1 R1): close 2 codex P2 findings on D1 schema
Round-1 codex review (`/codex review --base main`) returned 0 P1 + 2 P2.
Both findings closed in this commit.
P2-1 — Schema rule #2 hole on missing description_last_audit:
The `if/then` allOf branch only constrained `description_last_audit`
when the property was present, so an entry with `source_acquired: false`
that omitted the field passed both schema and lint. Spec § 3.1 firm rule
#2 says REQUIRES, which is strict (the field must be present, value must
be null or "none"). Schema gains `required: ["description_last_audit"]`
under the rule-2 `then` branch; lint mirrors with a friendly
"is missing" diagnostic instead of defaulting `entry.get(...)` to None.
New test pins the missing-field FAIL case.
P2-2 — description_source enum hard-coded v1..v3:
Spec § 3.1 yaml uses `bibliography_v<n>` as a template (any non-negative
integer n). The schema enum hard-coded v1..v3, so a corpus that follows
the documented format with v4+ would fail validation. Switched to a
regex `^(original_pdf|bibliography_v[0-9]+|secondary_summary)$` and added
three tests: arbitrary revision numbers (v0 / v4 / v17 / v999) PASS,
canonical values (original_pdf / secondary_summary / v1) still PASS,
unrelated strings (bib_v1 / bibliography_vX / typos) still FAIL.
Verification (local):
- 24/24 trust-schema tests pass (was 20 before R1 fixes);
- 12/12 SHA gate tests + 58/58 v3.6.7 mutation tests still green;
- All 4 baseline lints + sync_adapter_docs --check + corpus consumer
protocol all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v3.7.1 Step 0 R2): close codex P2 — anti-self-baseline guard on SHA gate
Round-2 codex review (`/codex review --base main`) returned 0 P1 + 1 P2
that's effectively P1-grade in scope: the SHA gate could self-baseline.
The hole: a PR that mutates `scripts/v3_6_7_inversion_manifest.json` AND
a v3.6.7-tagged PATTERN PROTECTION block in the same commit causes
`git log -1 -- v3_6_7_inversion_manifest.json` to resolve to that very
commit. The base block (`git show <commit>:<file>`) and the HEAD block
(read from worktree) then point at the same modified content, the SHA
hashes match trivially, and the boundary rule the gate exists to enforce
goes silently un-checked. Codex's framing: "the gate hashes modified
content as its own baseline."
Fix (per spec round-4 R4-002 single-source-of-truth + new round-2 P2 closure):
add an anti-self-baseline guard at the front of `check_byte_equivalence`.
The guard reads `v3_6_7_inversion_manifest.json` bytes at HEAD and at
`merge-base $GITHUB_BASE_REF / origin/<default-branch> HEAD`, and refuses
to run the SHA gate if the bytes differ — instructing the contributor to
land manifest amendments in a separate PR (under a v3.7+ amendment
process) so the next gate run sees the new manifest as its baseline.
Edge cases handled:
- Manifest absent at PR base → "manifest creation is not a v3.7.1-PR action"
- Manifest absent at HEAD but present at base → "deletion is not a
v3.7.1-PR action"
- Manifest unchanged → guard passes, fall through to `git log -1` derivation
- No PR base detectable (local detached state) → guard treated as advisory;
CI on the canonical repo catches the attack
Verification (local):
- New `test_anti_self_baseline_guard_rejects_manifest_mutation_in_pr`:
mutate manifest in worktree → lint refuses with the round-2 message.
- Pre-existing 12 SHA gate tests stay green; one (manifest-deletion test)
refined to accept either the new guard message or the legacy loader
error, since the guard now catches deletion earlier with a more precise
message.
- All 4 baseline lints + sync_adapter_docs + corpus consumer protocol +
literature corpus schema + 95 tests across the three v3.6.x suites
green.
Spec round-9 amendment will document the anti-self-baseline invariant
in §388 acceptance criteria; that's a separate spec PR. This commit lands
the runtime enforcement first (per `feedback_codex_iterative_spec_review_to_zero.md`
discipline — close findings before round-3, regardless of where the spec
text catches up).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v3.7.1 Step 0 R3): close codex P2 — include heading prefix in SHA range
Round-3 codex review caught a v3.6.7-extractor inheritance bug. Spec § 388
canonical range says "start at the LINE containing
`## PATTERN PROTECTION (v3.6.7)`", so the `## ` heading prefix is part
of the hashed bytes. But the v3.6.7 lint's `_extract_block` finds the
marker via case-insensitive substring search — it returns a slice
starting at `PATTERN...`, dropping the heading prefix.
That's harmless for v3.6.7's own invariant greps (they search keywords
inside the block; prefix doesn't matter), but the v3.7.1 SHA gate
inherited the same start position and would have accepted a
`## PATTERN PROTECTION (v3.6.7)` → `### PATTERN PROTECTION (v3.6.7)`
mutation as byte-equivalent. Spec wording would say the gate had
silently approved a heading-level demotion.
Fix: `_extract_block_bytes` now wraps the v3.6.7 extractor and walks
the start position backward to the start of the marker's line, so the
heading prefix bytes are inside the hashed range. End position and
termination logic untouched — byte-equivalence to the v3.6.7 extractor
is preserved everywhere except the prefix.
Two new tests:
- `test_heading_prefix_mutation_is_caught`: mutate `##` → `###` on
synthesis_agent.md → SHA gate FAILS (was previously a silent pass).
- `test_extractor_includes_heading_prefix_bytes`: unit-level check that
the extractor returns different bytes for H2 vs H3 input and that
the prefix bytes are literally in the returned slice.
This closes round-3's only finding (0 P1, 1 P2). The base-commit hash
in CI updates because the hash range now includes the previously-elided
prefix bytes — `[v3.7.1 SHA gate]` PASS lines show new sha256 prefixes
on all 3 protected files, but the comparison itself stays trivially
equal at the new commit.
Verification (local):
- 15/15 SHA gate tests + 24/24 trust schema tests + 58/58 v3.6.7
mutation tests = 97 total green.
- All 4 baselines + sync_adapter_docs + corpus consumer protocol +
literature corpus schema all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v3.7.1 Step 0 R4): close codex P2 — history-scan in anti-self-baseline guard
Round-4 codex review found a touch-and-revert bypass of the round-2
guard. The byte-only check at HEAD vs base lets the following pattern
through:
commit A: modify v3.6.7 manifest + modify v3.6.7 protected block
commit B: revert manifest to original bytes (block edit stays)
→ HEAD-vs-base manifest BYTES are equal (round-2 guard sees nothing wrong)
→ `git log -1 -- manifest` still resolves to commit B as the SHA gate's
baseline
→ `git show B:<protected>` returns the modified block content
→ HEAD reads the same modified block
→ SHA(B's modified) == SHA(HEAD's modified) → gate trivially passes
Codex's framing: "the guard needs to reject any manifest touch in
merge-base..HEAD, not just final content changes."
Fix: insert a `git log --format=%H merge-base..HEAD -- manifest` history
scan as the FIRST check in `_v3_6_7_manifest_unchanged_in_pr`. Any commit
that touches the manifest in the PR range — whether the touch is
ultimately retained at HEAD or reverted later — produces a non-empty
log output and the guard rejects with a list of the offending SHAs.
The round-2 byte-comparison stays as a defense-in-depth backstop in case
the path-filtered `git log` ever under-reports (corrupted history,
filter bug). The "missing at HEAD / missing at base" branches are kept
since they catch a class the history scan can't (manifest re-created
fresh in this PR).
New test:
- `test_anti_self_baseline_guard_history_scan_called` patches `_run_git`
to inject a synthetic non-empty `git log` result, asserts the guard
rejects with the round-2+round-4 closure message.
Pre-existing tests:
- `test_anti_self_baseline_guard_rejects_manifest_mutation_in_pr`
refined to accept either the byte-mismatch message OR the touched-by
message, since worktree mutations now fall through to the byte
backstop (history is unchanged, only worktree differs).
Verification (local):
- 16/16 SHA gate tests + 24/24 trust schema tests + 58/58 v3.6.7
mutation tests = 98 total green.
- All 4 baselines + sync_adapter_docs + corpus consumer protocol all green.
Spec round-9 amendment will document the history-scan invariant
alongside the round-2 byte-equality check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v3.7.1 Step 0 R4 followup): pin GITHUB_BASE_REF in guard tests
CI on `push` event runs revealed that the round-2 + round-4 anti-self-
baseline guard tests assumed PR context. Without `GITHUB_BASE_REF` set
(present on `pull_request` events, absent on `push`), the guard's
`_detect_pr_base_ref` returns None and falls through to advisory mode
("local detached state"), so the tests' "guard MUST reject" assertions
were not exercised on push runs.
Fix: both guard tests now `monkeypatch.setenv("GITHUB_BASE_REF", "main")`
to force the guard onto its real reject path regardless of trigger
event. The `pull_request` event run was already green; this fix unblocks
the matching `push` event run on the same commit.
No production-code change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v3.7.1 Step 1 R6): close codex P2 — strict 'none' literal in firm rule #2
Round-6 codex review caught a spec-vs-impl wording widening I introduced
without authority. Spec § 3.1 firm rule #2 (line 120) reads:
`source_acquired: false` REQUIRES `description_last_audit: none`
That is the literal sentinel string "none". Spec § 3.1 yaml at line 111
lists the value vocabulary as `<round_id> | none` — there is no null
alternative.
R1 closure had widened this to "null OR 'none'" by mistake. The schema
`then` branch carried `oneOf: [null, "none"]`; the lint allowed both.
That meant an entry with `source_acquired: false` and
`description_last_audit: null` passed CI even though the spec demands
the sentinel.
Fix:
- Schema rule-#2 then-branch: `{type: "string", const: "none"}` only.
- Lint rule-#2 check: only literal "none" passes; null is rejected
with a "literal sentinel string" diagnostic citing spec § 3.1 line 111.
- Top-level `description_last_audit` field type stays `[string, null]`
— null is still legal when source_acquired=true and the entry hasn't
been audited yet. The tightening is scoped to the rule-#2 case only.
Tests:
- `test_rule2_acquired_false_with_audit_null_passes` flipped to
`..._fails` with positive assertion that schema rejects + lint
surfaces the literal-only message. Old name removed (it asserted
the now-incorrect lenient behaviour).
- All other pre-existing tests still green; the `verified_false_does_not_constrain_method`
test fixture already used `description_last_audit: "none"` (string),
so it stays compatible.
Verification (local):
- 24/24 trust schema + 16/16 SHA gate + 58/58 v3.6.7 mutation = 98 green.
- All 4 baselines + sync_adapter_docs + corpus consumer + corpus schema green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v3.7.1 Step 1 R7): close codex P2 — agent prompts cascade for R6 strict-'none'
Round-7 codex review caught a cascade gap from R6. R6 tightened the
schema and lint to require literal "none" (rejecting null) when
source_acquired=false, but two agent prompts (bibliography_agent +
literature_strategist_agent) still told agents "MUST be null or 'none'".
An agent following its own prompt for an unacquired source could emit
description_last_audit: null and produce a passport that fails the new
CI lint — the documented agent contract diverged from the enforced
contract.
Per `feedback_cross_model_review_cascade_inconsistency.md`: large
revisions need a second-round codex pass to catch downstream cascade,
exactly the kind R7 surfaced.
Fix:
- bibliography_agent.md (Trust-Chain Discipline H2):
- Inline yaml comment on description_last_audit clarifies "null only
when source_acquired=true; rule-#2 case requires literal 'none'".
- Firm rule #2 prose rewritten to require literal "none", cite spec
line 120 + line 111 yaml vocabulary, and explain CI lint enforcement.
- literature_strategist_agent.md (Trust-Chain Discipline H2):
- Same yaml-comment + rewritten firm rule #2.
- Adds "such entries fail the trust-chain CI lint" guidance so the
consumer agent surfaces the inconsistency rather than silently
treating null as acceptable.
- literature_corpus_entry.schema.json field-level description:
- Clarifies that the field-level `[string, null]` permits both
broadly, but the rule-#2 then-branch tightens to literal "none"
only when source_acquired=false. Prevents future readers from
assuming the field-level wording is the full contract.
No production-code logic change in this commit (it's all prompts +
schema description text); the rule enforcement landed in R6.
Verification (local):
- 98 tests green across all three v3.6.x suites;
- All 4 baselines + sync_adapter_docs (no cascade — descriptions kept
their first-sentence summary intact) + corpus consumer + corpus
schema all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v3.7.1 Step 0 R8): close codex P2 — file-level vs block-level BOM stripping
Round-8 codex review caught a normalization scope error introduced by
R3. R3 wrapped the v3.6.7 extractor with line-start backtracking + a
helper called `_normalize_bytes` that stripped a leading UTF-8 BOM from
the EXTRACTED block. Spec § Step 0 says "the FILE's BOM (if any) is
excluded" — the exclusion is FILE-level (byte 0), not block-level.
Because all three current v3.6.7-protected blocks start MID-FILE,
inserting U+FEFF immediately before `## PATTERN PROTECTION (v3.6.7)`
created a block whose extracted slice started with the BOM bytes,
which `_normalize_bytes` then silently stripped, making the HEAD block
hash identical to the base hash even though the canonical byte range
had changed. Real content mutation, gate said PASS.
Codex's framing: "this should only strip a BOM when the block begins
at byte 0 (or normalize the full file before extraction)."
Fix:
- Renamed `_normalize_bytes` → `_strip_file_bom`. Old name kept as a
module-level alias only for backward compatibility with anything
that imports it; tests use the new name.
- `_extract_block_bytes` now takes raw `file_bytes: bytes` (was: a
decoded `str`) and applies `_strip_file_bom` BEFORE extraction.
This restricts BOM stripping to byte 0 of the file; any BOM that
appears later (e.g. inserted before a heading as a hidden mutation)
stays in the hashed range and trips the gate.
- Two call sites in `check_byte_equivalence` updated to pass raw
bytes; intermediate `.decode("utf-8", errors="replace")` calls
removed (the function now does the decode internally on
BOM-stripped bytes).
New tests:
- `test_extractor_strips_only_file_level_bom_not_block_level`: unit-
level check that file-level BOM (byte 0) IS stripped, but BOM
before heading (mid-file) is NOT.
- `test_bom_before_heading_attack_caught_by_lint`: end-to-end
mutation test that injects BOM right before the v3.6.7 marker on
disk and asserts the lint FAILS with BYTE-EQUIVALENCE FAIL.
Renamed test (signature change, semantics unchanged):
- `test_normalize_strips_bom_only_when_present` → `test_strip_file_bom_only_at_byte_zero`,
with an added assertion that mid-input BOM bytes are NOT stripped
(pinning the round-8 invariant at the helper level too).
Verification (local):
- 18/18 SHA gate + 24/24 trust schema + 58/58 v3.6.7 mutation = 100 green.
- All 4 baselines + sync_adapter_docs + corpus consumer + corpus schema green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v3.7.1 Step 0 R9): close codex P3 — anchor marker search to heading line
Round-9 codex review caught a false-fail risk introduced by R3's
substring-based marker search. A v3.7.1 PR may legitimately add prose
BEFORE the protected block that mentions `PATTERN PROTECTION (v3.6.7)`
— for instance, a "Two-Layer Citation Emission" section's introductory
paragraph that explains how new invariants relate to the existing
v3.6.7 PATTERN PROTECTION block. The pre-round-9 substring search would
have matched the prose mention first, hashed the wrong byte range, and
false-failed CI on a valid edit.
Codex's framing: "anchor the lookup to the Markdown heading line."
Note: this is a P3 finding (not P2/P1). Per
`feedback_codex_review_vs_resume_audit_scope.md`, P3-only is ship-OK,
but the false-fail risk is real correctness-grade — fixing now keeps
the lint usable for legitimate v3.7.1 work.
Fix:
- Add `import re`.
- Replace the substring `text.lower().find(marker.lower())` with an
anchored heading regex: `(?im)^[ \t]*#{1,3}[ \t]+<marker>`. The
`(?m)` MULTILINE flag makes `^` match line starts; `(?i)` matches
the case-insensitive marker per the v3.6.7 lint convention. NO `\b`
after the marker (it ends with `)`, a non-word char, so `\b` would
not match there).
Tests:
- New `test_prose_mention_of_marker_does_not_misanchor_extractor`:
text containing a prose mention of the marker before the actual
heading; extractor must return the heading-anchored block, NOT the
prose mention. Asserts the returned bytes start with `## PATTERN
PROTECTION (v3.6.7)\\n` and that the prose paragraph's content is
not swallowed.
- `test_bom_before_heading_attack_caught_by_lint` updated to accept
either "BYTE-EQUIVALENCE FAIL" or "marker missing at PR HEAD" as
the rejection diagnostic. After R9, BOM bytes injected before the
heading break the `^[ \\t]*#{1,3}` shape so the regex misses, and
the lint falls into the missing-marker branch — still a hard fail,
just a different message. Both are correct.
Verification (local):
- 19/19 SHA gate (added 1 prose-mention test) + 24/24 trust schema +
58/58 v3.6.7 mutation = 101 total green.
- All 4 baselines + sync_adapter_docs + corpus consumer + corpus
schema all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v3.7.1 Step 0 R10): close codex P2 — independent block-end search
Round-10 codex review caught an incomplete fix from R9. R9 anchored the
block START to a Markdown heading line, but `_extract_block_bytes` was
still calling the v3.6.7 legacy extractor and using `len(block)` from
that result as the slice length. The v3.6.7 extractor uses substring
search — when prose before the protected block mentions
`PATTERN PROTECTION (v3.6.7)`, it returns the slice starting at the
PROSE position. So `len(block)` equalled "prose-to-next-heading", and
adding that length to the heading-anchored line_start produced a
slice covering the WRONG byte range (truncated or extended depending
on relative offsets). CI would falsely fail valid v3.7.1 PRs.
Codex's framing: "the slice length comes from the wrong prose-to-
heading fragment, so the SHA range is truncated/extended."
Fix: stop calling the v3.6.7 legacy extractor entirely. Anchor BOTH
endpoints to heading lines:
- START: `(?im)^[ \t]*#{1,3}[ \t]+<marker>` (round-9, kept).
- END: independent search for the next H1/H2/H3 heading AFTER the
marker line, or EOF — `(?m)^[ \t]*#{1,3}[ \t]+`. Mirrors the
v3.6.7 lint's heading-to-next-heading-or-EOF termination semantics
but starts the END search from the line AFTER the marker, not from
the `pos = len(marker)` cursor the substring extractor used.
Removed unused import: `_extract_block as _v3_6_7_extract_block` from
`check_v3_6_7_pattern_protection`. The v3.6.8 lint no longer delegates
any extraction work to the v3.6.7 extractor — it computes the canonical
range from scratch using heading-anchored regexes. (The v3.6.7
PROTECTION_BLOCK constant is still imported as the marker text source
of truth.)
New test:
- `test_prose_mention_does_not_truncate_block_range`: prose paragraph
mentions marker before the real heading; extractor must return the
block from the heading through the next heading (full body),
excluding the prose paragraph and the v3.7.1 section's heading.
Verification (local):
- 20/20 SHA gate (added 1 R10 closure test) + 24/24 trust schema +
58/58 v3.6.7 mutation = 102 total green.
- All 4 baselines + sync_adapter_docs + corpus consumer + corpus
schema all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
6aaef85a97
commit
5f107c7bad
@@ -14,6 +14,14 @@ jobs:
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# v3.7.1 byte-equivalence SHA gate derives the v3.6.7 base commit
|
||||
# via `git log -1 -- scripts/v3_6_7_inversion_manifest.json`. A
|
||||
# shallow clone (default fetch-depth: 1) would render that lookup
|
||||
# vacuous. Deepening the clone here lets the lint hash the base
|
||||
# block directly without falling back to `git fetch --unshallow`.
|
||||
# Spec: 2026-04-30-ars-v3.6.8-trust-provenance-and-drift-transparency-spec.md § Step 0 (round-6 R6-002 + round-7 R7-001).
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
@@ -117,6 +125,33 @@ jobs:
|
||||
PYTHONPATH: .
|
||||
run: python3 -m unittest scripts.test_check_v3_6_7_pattern_protection -v
|
||||
|
||||
- name: Validate v3.7.1 byte-equivalence SHA gate (v3.6.7 boundary)
|
||||
# v3.7.1 work must NOT mutate v3.6.7-tagged PATTERN PROTECTION blocks.
|
||||
# This lint hashes each block at PR HEAD and at the v3.6.7 base commit
|
||||
# (derived via `git log -1 -- scripts/v3_6_7_inversion_manifest.json`)
|
||||
# and asserts equality. Requires fetch-depth: 0 on the checkout step.
|
||||
run: python3 scripts/check_v3_6_8_pattern_protection.py
|
||||
|
||||
- name: Run v3.7.1 SHA gate mutation tests
|
||||
env:
|
||||
PYTHONPATH: .
|
||||
run: |
|
||||
pip install pytest
|
||||
pytest scripts/test_check_v3_6_8_pattern_protection.py -v
|
||||
|
||||
- name: Validate v3.7.1 trust-chain frontmatter schema
|
||||
# Step 1 of v3.7.1: enforces three firm rules from spec §3.1
|
||||
# (verified⇒acquired+method, not-acquired⇒no-real-audit-round, no
|
||||
# literal human_read_*) across literature_corpus[] entries.
|
||||
run: python3 scripts/check_v3_6_8_frontmatter_trust_schema.py
|
||||
|
||||
- name: Run v3.7.1 trust-chain frontmatter schema tests
|
||||
env:
|
||||
PYTHONPATH: .
|
||||
run: |
|
||||
pip install pytest pyyaml jsonschema
|
||||
pytest scripts/test_check_v3_6_8_frontmatter_trust_schema.py -v
|
||||
|
||||
- name: Run v3.6.7 Step 6 audit schema + helper tests (Phase 6.2 + 6.4)
|
||||
env:
|
||||
PYTHONPATH: .
|
||||
|
||||
@@ -274,6 +274,34 @@ The note appears regardless of which Step 2 case fires next. Step 2 dispatch fol
|
||||
|
||||
F4a/b/c are mutually exclusive by trigger. F4d applies only when zero entries declare `obtained_at`; F4e and F4f compose. Never silently fill in or guess; never demand presence. See spec §4.2 for the full precedence reasoning.
|
||||
|
||||
## Trust-Chain Frontmatter Discipline (v3.7.1+)
|
||||
|
||||
Schema 9 `literature_corpus[]` entries carry seven trust-chain fields that distinguish three previously-conflated confidence levels: source acquisition, source verification against the original artifact, and human-read attestation. As a downstream consumer of `literature_corpus[]`, you read these fields when filtering or ranking entries; you MUST NOT mutate or fabricate them.
|
||||
|
||||
### The seven entry-stored trust fields (read-only from this agent's perspective)
|
||||
|
||||
```yaml
|
||||
source_acquired: true | false # original PDF/HTML/dataset is on disk
|
||||
source_acquisition_date: <ISO 8601> # only meaningful when acquired=true
|
||||
source_acquisition_path: <relative path> # only meaningful when acquired=true
|
||||
source_verified_against_original: true | false # AI cross-checked against original content
|
||||
source_verification_method: codex_audit | manual_grep | vision_check | none
|
||||
description_source: original_pdf | bibliography_v<n> | secondary_summary
|
||||
description_last_audit: <round_id> | "none" | null # null only when source_acquired=true; rule-#2 case requires literal "none"
|
||||
```
|
||||
|
||||
### Three firm rules
|
||||
|
||||
1. **Verified ⇒ acquired AND real method.** Treat `source_verified_against_original: true` as meaningful only when paired with `source_acquired: true` AND `source_verification_method ∈ {codex_audit, manual_grep, vision_check}`. Entries that violate this combination are spec-broken; surface them to the user rather than silently treating them as verified.
|
||||
|
||||
2. **Not acquired ⇒ literal `"none"` audit sentinel.** When `source_acquired: false`, `description_last_audit` MUST be the literal string `"none"` (round-6 codex P2 closure aligns this with spec § 3.1 line 120 + line 111 yaml vocabulary; null is rejected for the rule-#2 case). If you encounter an entry with `source_acquired: false` and `description_last_audit: "round-3-codex"` (or similar — including null), treat the audit claim as untrusted and surface the inconsistency. Such entries fail the trust-chain CI lint, so they are also a signal that the upstream adapter / `bibliography_agent` is producing spec-broken output.
|
||||
|
||||
3. **NEVER emit `human_read_source` or `human_read_at` on the entry.** Those keys are USER-OWNED and derived at read-time from the §3.6 peer file `<session>_human_read_log.yaml`. The entry schema is `additionalProperties: false`; emitting these keys would break the v3.6.5 corpus-consumer protocol that this agent depends on. If you need the human-read signal, the orchestrator surfaces it via the §3.6 peer-file join — do not write it to the entry yourself.
|
||||
|
||||
### Refusal-on-uncertain rule
|
||||
|
||||
When the verification fields are missing or inconsistent (e.g. `source_verified_against_original: true` with `source_acquired: false`), do not paper over the inconsistency. Treat such entries as `verified=false` for downstream filtering and flag the inconsistency in your search-strategy report so the user can correct the upstream adapter or `bibliography_agent` output.
|
||||
|
||||
## Detailed Execution Algorithm
|
||||
|
||||
### Complete Search Workflow (4-Layer Progressive Strategy)
|
||||
|
||||
@@ -43,9 +43,16 @@ Refer to the [`literature_corpus_entry` schema](../../../shared/contracts/passpo
|
||||
| `abstract` | string | PRIVATE FIELD. |
|
||||
| `adapter_name` | string | Optional. |
|
||||
| `adapter_version` | string | — |
|
||||
| `description_last_audit` | null \| string | v3.7.1 trust-chain field. |
|
||||
| `description_source` | string | v3.7.1 trust-chain field. |
|
||||
| `doi` | string | DOI without leading 'doi:' or URL prefix. |
|
||||
| `obtained_at` | string | Strongly recommended. |
|
||||
| `obtained_via` | string | Strongly recommended. |
|
||||
| `source_acquired` | boolean | v3.7.1 trust-chain field (spec § 3.1, D1). |
|
||||
| `source_acquisition_date` | string | v3.7.1 trust-chain field. |
|
||||
| `source_acquisition_path` | string | v3.7.1 trust-chain field. |
|
||||
| `source_verification_method` | string | v3.7.1 trust-chain field. |
|
||||
| `source_verified_against_original` | boolean | v3.7.1 trust-chain field. |
|
||||
| `tags` | array | User-assigned tags from the source KB. |
|
||||
| `user_notes` | string | PRIVATE FIELD. |
|
||||
| `venue` | string | — |
|
||||
|
||||
@@ -216,6 +216,34 @@ The note appears regardless of which Step 2 case fires next. Step 2 dispatch fol
|
||||
|
||||
F4a/b/c are mutually exclusive by trigger. F4d applies only when zero entries declare `obtained_at`; F4e and F4f compose. Never silently fill in or guess; never demand presence. See spec §4.2 for the full precedence reasoning.
|
||||
|
||||
## Trust-Chain Frontmatter Discipline (v3.7.1+)
|
||||
|
||||
Schema 9 `literature_corpus[]` entries carry seven trust-chain fields that distinguish three previously-conflated confidence levels: source acquisition, source verification against the original artifact, and human-read attestation. When emitting, mutating, or describing entries, observe the three firm rules and the refusal-on-uncertain rule below.
|
||||
|
||||
### The seven entry-stored trust fields
|
||||
|
||||
```yaml
|
||||
source_acquired: true | false # original PDF/HTML/dataset is on disk
|
||||
source_acquisition_date: <ISO 8601> # only meaningful when acquired=true
|
||||
source_acquisition_path: <relative path> # only meaningful when acquired=true
|
||||
source_verified_against_original: true | false # AI cross-checked against original content
|
||||
source_verification_method: codex_audit | manual_grep | vision_check | none
|
||||
description_source: original_pdf | bibliography_v<n> | secondary_summary
|
||||
description_last_audit: <round_id> | "none" | null # null only when source_acquired=true; rule-#2 case requires literal "none"
|
||||
```
|
||||
|
||||
### Three firm rules
|
||||
|
||||
1. **Verified ⇒ acquired AND real method.** `source_verified_against_original: true` REQUIRES `source_acquired: true` AND `source_verification_method ∈ {codex_audit, manual_grep, vision_check}`. The literal `none` is enumerated for shape uniformity but is FORBIDDEN here. If the original source is not on disk, do not claim verification — emit `source_verified_against_original: false` regardless of internal-consistency checks performed against derivative bibliographies.
|
||||
|
||||
2. **Not acquired ⇒ literal `"none"` audit sentinel.** `source_acquired: false` REQUIRES `description_last_audit` to be the literal string `"none"`. Spec § 3.1 line 120 reads "REQUIRES description_last_audit: none" (sentinel); the yaml vocabulary at line 111 lists `<round_id> | none` with no null alternative. `null` is rejected by both the JSON Schema rule-#2 then-branch and the trust-chain lint when `source_acquired: false` (round-6 codex P2 closure). When `source_acquired: true` and the entry is unaudited, `null` is fine — the strict-`"none"` rule applies only to the rule-#2 case.
|
||||
|
||||
3. **NEVER emit `human_read_source` or `human_read_at` on the entry.** Those keys are USER-OWNED and live in the §3.6 peer file `<session>_human_read_log.yaml`, set only by the user-issued `/ars-mark-read <citation_key>` command. The entry schema is `additionalProperties: false` and adapter-owned (per `academic-pipeline/references/literature_corpus_consumers.md`); emitting these keys from `bibliography_agent` would mutate `literature_corpus[]` and break the v3.6.5 corpus-consumer protocol. The orchestrator joins the peer file at frontmatter-read time to derive the human-read signal.
|
||||
|
||||
### Refusal-on-uncertain rule
|
||||
|
||||
When you have NOT retrieved the original source — or have retrieved it but have NOT performed an affirmative verification step (codex_audit / manual_grep / vision_check) — you MUST set `source_verified_against_original: false`. Do not infer verification from the fact that a derivative bibliography agrees with the entry; that is description-source consistency (covered by `description_source` and `description_last_audit`), not source verification. When in doubt, emit `false` and let downstream consumers see the honest signal.
|
||||
|
||||
## APA 7.0 Quick Reference
|
||||
|
||||
Reference: `references/apa7_style_guide.md`
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ARS v3.7.1 trust-chain frontmatter lint (spec § 3.1 firm rules).
|
||||
|
||||
Spec: docs/design/2026-04-30-ars-v3.6.8-trust-provenance-and-drift-transparency-spec.md
|
||||
§ 3.1 D1 — Frontmatter trust-strength conflation
|
||||
§ Step 1 — Frontmatter schema split
|
||||
|
||||
Validates the three firm rules across all literature_corpus[] entries in a
|
||||
passport (or, with --fixture, against an arbitrary YAML / JSON file). Rules:
|
||||
|
||||
Rule #1 (round-2 R2-007 amend):
|
||||
source_verified_against_original=true
|
||||
⟹ source_acquired=true
|
||||
AND source_verification_method ∈ {codex_audit, manual_grep, vision_check}
|
||||
The literal 'none' verification method is enumerated for shape
|
||||
uniformity but is FORBIDDEN in conjunction with verified=true.
|
||||
|
||||
Rule #2:
|
||||
source_acquired=false
|
||||
⟹ description_last_audit is null or the literal string 'none'.
|
||||
(No original source means an audit cannot be substantive.)
|
||||
|
||||
Rule #3 (round-1 codex F-005 amend):
|
||||
A literature_corpus[] entry MUST NOT carry a literal
|
||||
`human_read_source` or `human_read_at` field. These keys are derived
|
||||
at read-time from the §3.6 peer file `<session>_human_read_log.yaml`
|
||||
and are USER-OWNED. Adapters and consumer agents emitting these
|
||||
keys would mutate `literature_corpus[]` (which is
|
||||
`additionalProperties: false` and adapter-owned per
|
||||
`academic-pipeline/references/literature_corpus_consumers.md`).
|
||||
|
||||
Defense-in-depth: the same rules are encoded in the JSON Schema's `allOf`
|
||||
branches (rules #1 and #2) and via `additionalProperties: false` (rule #3).
|
||||
This lint produces friendlier error messages, cites spec sections, and
|
||||
runs over passport corpora — a level above per-entry schema validation.
|
||||
|
||||
Exit codes: 0 on pass, 1 on any rule violation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError as e:
|
||||
print(
|
||||
f"Missing dependency: {e}. Install with: pip install pyyaml",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
EXAMPLES_ROOT = REPO_ROOT / "scripts" / "adapters" / "examples"
|
||||
|
||||
VALID_VERIFICATION_METHODS = {"codex_audit", "manual_grep", "vision_check", "none"}
|
||||
TRUE_VERIFICATION_METHODS = {"codex_audit", "manual_grep", "vision_check"}
|
||||
|
||||
# Per spec §3.6 firm rule #1 + §3.1 firm rule #3: literal `human_read_*`
|
||||
# fields are forbidden on entries; they live in the peer file derived at
|
||||
# read-time. Both keys are caught here for symmetry (the peer file carries
|
||||
# `marked_at`; downstream join produces `human_read_source` and
|
||||
# `human_read_at` as derived fields).
|
||||
FORBIDDEN_HUMAN_READ_KEYS = ("human_read_source", "human_read_at")
|
||||
|
||||
|
||||
def _load_yaml_or_json(path: Path) -> Any:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
try:
|
||||
if path.suffix.lower() == ".json":
|
||||
return json.loads(text)
|
||||
return yaml.safe_load(text)
|
||||
except (yaml.YAMLError, json.JSONDecodeError) as exc:
|
||||
raise SystemExit(f"[ARS-V3.7.1 LINT ERROR: cannot parse {path}: {exc}]")
|
||||
|
||||
|
||||
def _iter_entries(payload: Any) -> Iterable[dict[str, Any]]:
|
||||
"""Yield entries from common shapes:
|
||||
- {literature_corpus: [...entries...]} (passport shape)
|
||||
- [...entries...] (bare entry list)
|
||||
- {... entry fields ...} (single entry)
|
||||
"""
|
||||
if isinstance(payload, dict):
|
||||
if "literature_corpus" in payload and isinstance(payload["literature_corpus"], list):
|
||||
for entry in payload["literature_corpus"]:
|
||||
if isinstance(entry, dict):
|
||||
yield entry
|
||||
return
|
||||
if "citation_key" in payload: # bare single entry
|
||||
yield payload
|
||||
return
|
||||
# Otherwise: nothing to scan.
|
||||
return
|
||||
if isinstance(payload, list):
|
||||
for entry in payload:
|
||||
if isinstance(entry, dict):
|
||||
yield entry
|
||||
|
||||
|
||||
def _entry_label(entry: dict[str, Any], idx: int) -> str:
|
||||
return entry.get("citation_key") or f"<entry index={idx}>"
|
||||
|
||||
|
||||
def check_entry(entry: dict[str, Any], label: str) -> list[str]:
|
||||
"""Return a list of human-readable rule-violation messages."""
|
||||
errors: list[str] = []
|
||||
|
||||
# Rule #3: forbidden human_read_* keys.
|
||||
for key in FORBIDDEN_HUMAN_READ_KEYS:
|
||||
if key in entry:
|
||||
errors.append(
|
||||
f" [{label}] Rule #3 violated: literal {key!r} field on "
|
||||
f"entry. Per spec §3.1 firm rule #3 + §3.6 firm rule #1, "
|
||||
f"{key!r} is DERIVED from the §3.6 peer file "
|
||||
f"`<session>_human_read_log.yaml` and MUST NOT appear on "
|
||||
f"literature_corpus[] entries. Remove the field."
|
||||
)
|
||||
|
||||
# Rule #1: source_verified_against_original=true triggers preconditions.
|
||||
verified = entry.get("source_verified_against_original")
|
||||
if verified is True:
|
||||
acquired = entry.get("source_acquired")
|
||||
method = entry.get("source_verification_method")
|
||||
if acquired is not True:
|
||||
errors.append(
|
||||
f" [{label}] Rule #1 violated: source_verified_against_original=true "
|
||||
f"REQUIRES source_acquired=true (got source_acquired={acquired!r}). "
|
||||
f"Spec §3.1 firm rule #1: cannot claim verification against an "
|
||||
f"original that is not on disk."
|
||||
)
|
||||
if method is None:
|
||||
errors.append(
|
||||
f" [{label}] Rule #1 violated: source_verified_against_original=true "
|
||||
f"REQUIRES source_verification_method ∈ "
|
||||
f"{{codex_audit, manual_grep, vision_check}} (got missing field). "
|
||||
f"Spec §3.1 firm rule #1 round-2 R2-007 amend."
|
||||
)
|
||||
elif method not in TRUE_VERIFICATION_METHODS:
|
||||
errors.append(
|
||||
f" [{label}] Rule #1 violated: source_verified_against_original=true "
|
||||
f"REQUIRES source_verification_method ∈ "
|
||||
f"{{codex_audit, manual_grep, vision_check}} (got {method!r}). "
|
||||
f"Spec §3.1 firm rule #1 round-2 R2-007 amend: 'none' is "
|
||||
f"enumerated for shape uniformity but is FORBIDDEN here."
|
||||
)
|
||||
|
||||
# Rule #2: source_acquired=false ⇒ description_last_audit MUST be present
|
||||
# AND its value MUST be the literal string "none". Round-1 codex P2 closure
|
||||
# made the field strictly required (a missing field is NOT equivalent to
|
||||
# null). Round-6 codex P2 closure tightened the value to the literal
|
||||
# sentinel "none" only — null is rejected — because spec §3.1 firm rule #2
|
||||
# reads "REQUIRES description_last_audit: none" (literal sentinel), and the
|
||||
# spec's value vocabulary at §3.1 line 111 lists `<round_id> | none` with
|
||||
# no null alternative.
|
||||
if entry.get("source_acquired") is False:
|
||||
if "description_last_audit" not in entry:
|
||||
errors.append(
|
||||
f" [{label}] Rule #2 violated: source_acquired=false REQUIRES "
|
||||
f"description_last_audit to be present (with value 'none'). "
|
||||
f"The field is missing. Spec §3.1 firm rule #2: no original "
|
||||
f"means audit cannot be substantive, so the entry MUST "
|
||||
f"explicitly carry description_last_audit: 'none'."
|
||||
)
|
||||
else:
|
||||
last_audit = entry["description_last_audit"]
|
||||
if last_audit != "none":
|
||||
errors.append(
|
||||
f" [{label}] Rule #2 violated: source_acquired=false REQUIRES "
|
||||
f"description_last_audit: 'none' — the literal sentinel "
|
||||
f"string (got {last_audit!r}). Spec §3.1 firm rule #2 + "
|
||||
f"§3.1 yaml at line 111 (value vocabulary `<round_id> | "
|
||||
f"none`) — null is NOT an accepted alternative for the "
|
||||
f"rule-#2 case (round-6 codex P2 closure)."
|
||||
)
|
||||
|
||||
# Optional sanity for verification_method enumeration (catches typos
|
||||
# the schema would also catch, but with friendlier message + spec cite).
|
||||
method = entry.get("source_verification_method")
|
||||
if method is not None and method not in VALID_VERIFICATION_METHODS:
|
||||
errors.append(
|
||||
f" [{label}] Invalid source_verification_method: {method!r}. "
|
||||
f"Allowed: {sorted(VALID_VERIFICATION_METHODS)}."
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def check_payload(payload: Any, source: str) -> list[str]:
|
||||
failures: list[str] = []
|
||||
for idx, entry in enumerate(_iter_entries(payload)):
|
||||
label = _entry_label(entry, idx)
|
||||
for err in check_entry(entry, label):
|
||||
failures.append(err)
|
||||
if failures:
|
||||
failures.insert(0, f"[ARS-V3.7.1 LINT ERROR: trust-chain rule violations in {source}]")
|
||||
return failures
|
||||
|
||||
|
||||
def _scan_examples() -> int:
|
||||
"""Scan all expected_passport.yaml fixtures under scripts/adapters/examples."""
|
||||
if not EXAMPLES_ROOT.exists():
|
||||
print(f"[ARS-V3.7.1 LINT ERROR: examples root missing at {EXAMPLES_ROOT}]")
|
||||
return 1
|
||||
failures: list[str] = []
|
||||
fixture_count = 0
|
||||
for path in sorted(EXAMPLES_ROOT.rglob("expected_passport.yaml")):
|
||||
fixture_count += 1
|
||||
payload = _load_yaml_or_json(path)
|
||||
rel = path.relative_to(REPO_ROOT)
|
||||
failures.extend(check_payload(payload, str(rel)))
|
||||
if fixture_count == 0:
|
||||
print("[ARS-V3.7.1 LINT WARN: no expected_passport.yaml fixtures found under "
|
||||
f"{EXAMPLES_ROOT.relative_to(REPO_ROOT)}]")
|
||||
if failures:
|
||||
print("\n".join(failures))
|
||||
return 1
|
||||
print(f"[v3.7.1 trust-schema lint] PASSED ({fixture_count} fixture(s) scanned)")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
|
||||
parser.add_argument(
|
||||
"--fixture",
|
||||
type=Path,
|
||||
help="Scan a single passport YAML / JSON file instead of the examples dir.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
if args.fixture is not None:
|
||||
if not args.fixture.exists():
|
||||
print(f"[ARS-V3.7.1 LINT ERROR: fixture not found: {args.fixture}]")
|
||||
return 1
|
||||
payload = _load_yaml_or_json(args.fixture)
|
||||
failures = check_payload(payload, str(args.fixture))
|
||||
if failures:
|
||||
print("\n".join(failures))
|
||||
return 1
|
||||
print(f"[v3.7.1 trust-schema lint] PASSED ({args.fixture})")
|
||||
return 0
|
||||
return _scan_examples()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,527 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ARS v3.7.1 byte-equivalence SHA gate for v3.6.7-tagged PATTERN PROTECTION blocks.
|
||||
|
||||
Spec: docs/design/2026-04-30-ars-v3.6.8-trust-provenance-and-drift-transparency-spec.md
|
||||
§ Step 0 — Lint manifest separation (round-1 codex F-004 amend)
|
||||
|
||||
Boundary rule (per spec):
|
||||
- v3.7.1 work does NOT modify the v3.6.7-tagged PATTERN PROTECTION blocks in
|
||||
synthesis_agent.md / research_architect_agent.md / report_compiler_agent.md.
|
||||
- v3.7.1 MAY add new prompt sections (e.g. "Two-Layer Citation Emission")
|
||||
OUTSIDE those v3.6.7-tagged blocks; those v3.6.8-tagged invariants ride
|
||||
this script's own manifest (scripts/v3_6_8_inversion_manifest.json), which
|
||||
starts empty in PR-1 and is populated by Step 3a.
|
||||
|
||||
Single source of truth (round-4 R4-002 + round-5 R5-001 + round-6 R6-002):
|
||||
- The v3.6.7 frozen manifest at scripts/v3_6_7_inversion_manifest.json is the
|
||||
single source of truth for the protected file LIST.
|
||||
- The v3.6.7 protected CONTENT is whatever the v3.6.7-tagged block shows at
|
||||
the v3.6.7 manifest's most recent modifying commit (derived via
|
||||
`git log -1 --format=%H scripts/v3_6_7_inversion_manifest.json`).
|
||||
- v3.7.1 lint computes SHA on demand at runtime: hash(block at PR HEAD) ==
|
||||
hash(block at v3.6.7 base commit). No stored expected SHAs; no dual truth.
|
||||
|
||||
Shallow-clone safety (round-6 R6-002 + round-7 R7-001):
|
||||
- `actions/checkout@v4` defaults to fetch-depth: 1 in CI; that would render
|
||||
`git log -1` vacuous. This lint detects shallow clones and either fetches
|
||||
--unshallow against the default branch or hard-fails with a fix-it message.
|
||||
|
||||
Exit codes: 0 on pass, 1 on any failure (including shallow-clone refusal).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Reuse v3.6.7 lint's heading-based block extractor for byte-equivalence.
|
||||
# The extractor must be byte-equivalent between the two lints; the spec
|
||||
# explicitly requires the SHARED function (see spec § Step 0 line ~389:
|
||||
# "The extractor is the byte-equivalent function shared between v3.6.7 lint
|
||||
# and v3.7.1 lint to guarantee identical results").
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from check_v3_6_7_pattern_protection import ( # noqa: E402
|
||||
PROTECTION_BLOCK as V3_6_7_PROTECTION_BLOCK,
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
V3_6_7_MANIFEST = REPO_ROOT / "scripts" / "v3_6_7_inversion_manifest.json"
|
||||
V3_6_8_MANIFEST = REPO_ROOT / "scripts" / "v3_6_8_inversion_manifest.json"
|
||||
|
||||
# Byte-order mark stripped per spec § Step 0: "the file's BOM (if any) is
|
||||
# excluded; trailing whitespace of the last block line is preserved".
|
||||
_BOM = b"\xef\xbb\xbf"
|
||||
|
||||
|
||||
def _run_git(args: list[str], cwd: Path = REPO_ROOT) -> tuple[int, str, str]:
|
||||
"""Run git and return (returncode, stdout, stderr) as decoded strings."""
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
||||
|
||||
|
||||
def _resolve_default_branch() -> tuple[str | None, str | None]:
|
||||
"""Resolve the repo's default branch via the spec's three-step ladder.
|
||||
|
||||
(1) `git symbolic-ref --quiet --short refs/remotes/origin/HEAD` → strip 'origin/'
|
||||
(2) `$GITHUB_DEFAULT_BRANCH` env (GitHub Actions fallback)
|
||||
(3) None — caller must hard-fail.
|
||||
"""
|
||||
rc, out, _ = _run_git(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"])
|
||||
if rc == 0 and out.startswith("origin/"):
|
||||
return out[len("origin/"):], None
|
||||
env_default = os.environ.get("GITHUB_DEFAULT_BRANCH")
|
||||
if env_default:
|
||||
return env_default, None
|
||||
return None, (
|
||||
"[ARS-V3.7.1 LINT ERROR: default branch unresolvable; clone must "
|
||||
"include origin/HEAD or set GITHUB_DEFAULT_BRANCH env]"
|
||||
)
|
||||
|
||||
|
||||
def _ensure_full_clone() -> str | None:
|
||||
"""Return None on success, error string on hard-fail.
|
||||
|
||||
If the repo is a shallow clone, attempt `git fetch --unshallow origin
|
||||
<default-branch>` first; if that itself fails, hard-fail.
|
||||
"""
|
||||
rc, out, _ = _run_git(["rev-parse", "--is-shallow-repository"])
|
||||
if rc != 0:
|
||||
return f"[ARS-V3.7.1 LINT ERROR: cannot determine clone depth: {out!r}]"
|
||||
if out.strip().lower() != "true":
|
||||
return None # full clone — proceed
|
||||
default_branch, err = _resolve_default_branch()
|
||||
if err is not None:
|
||||
return err
|
||||
rc, out, stderr = _run_git(["fetch", "--unshallow", "origin", default_branch])
|
||||
if rc != 0:
|
||||
return (
|
||||
"[ARS-V3.7.1 LINT ERROR: shallow clone detected; set fetch-depth: "
|
||||
f"0 in checkout step before running v3.7.1 byte-equivalence "
|
||||
f"check (unshallow attempt failed: {stderr!r})]"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _v3_6_7_base_commit() -> tuple[str | None, str | None]:
|
||||
"""Derive the v3.6.7 base commit via `git log -1` against the v3.6.7 manifest.
|
||||
|
||||
This is the single source of truth derivation per spec § Step 0
|
||||
(round-4 R4-002 + round-5 R5-001 + round-6 R6-002 amend; no stored
|
||||
base_commit field, no dual truth).
|
||||
"""
|
||||
rc, out, stderr = _run_git([
|
||||
"log", "-1", "--format=%H", "--",
|
||||
"scripts/v3_6_7_inversion_manifest.json",
|
||||
])
|
||||
if rc != 0 or not out:
|
||||
return None, (
|
||||
"[ARS-V3.7.1 LINT ERROR: cannot derive v3.6.7 base commit "
|
||||
f"from `git log -1 -- scripts/v3_6_7_inversion_manifest.json`: "
|
||||
f"rc={rc} stderr={stderr!r}]"
|
||||
)
|
||||
return out.strip(), None
|
||||
|
||||
|
||||
def _detect_pr_base_ref() -> str | None:
|
||||
"""Return a ref that names the PR's base for anti-self-baseline guard.
|
||||
|
||||
Order: $GITHUB_BASE_REF (CI fast path) → origin/<default-branch> (resolved
|
||||
via the same ladder as the shallow-clone safety check). Returns None when
|
||||
no remote / default branch is reachable (e.g. detached local check on a
|
||||
fork without origin); callers treat that as "skip the guard, fall back to
|
||||
derivation alone" — local-only attacks are out of scope (the user can see
|
||||
their own diff).
|
||||
"""
|
||||
env_base = os.environ.get("GITHUB_BASE_REF")
|
||||
if env_base:
|
||||
return f"origin/{env_base}"
|
||||
default_branch, _ = _resolve_default_branch()
|
||||
if default_branch:
|
||||
return f"origin/{default_branch}"
|
||||
return None
|
||||
|
||||
|
||||
def _v3_6_7_manifest_unchanged_in_pr() -> tuple[bool, str | None]:
|
||||
"""Anti-self-baseline guard (round-2 + round-4 codex P2 closure).
|
||||
|
||||
Without this, a PR could mutate `scripts/v3_6_7_inversion_manifest.json`
|
||||
AND a v3.6.7-tagged PATTERN PROTECTION block, causing the SHA gate to
|
||||
hash modified content against itself.
|
||||
|
||||
Round-2 guard (initial): compare manifest bytes at HEAD vs at
|
||||
`merge-base <pr-base> HEAD`; refuse to run on byte-difference.
|
||||
|
||||
Round-4 closure: byte-equality at HEAD is NOT sufficient. A PR with
|
||||
commit A (modify manifest + modify protected block) followed by
|
||||
commit B (revert manifest to original bytes; leave protected block
|
||||
edit) leaves HEAD-vs-base manifest BYTES equal, but `git log -1
|
||||
-- manifest` still resolves to commit B as the baseline, and
|
||||
`git show B:<protected>` returns the modified content — self-baseline
|
||||
attack reappears. Fix: also reject any commit that *touches* the
|
||||
manifest in the `merge-base..HEAD` range, regardless of final bytes.
|
||||
|
||||
Returns (True, None) on success or when the guard cannot be evaluated
|
||||
(no PR base detectable — local detached state). Returns (False, msg)
|
||||
when the manifest changed in the PR or was touched by any PR commit.
|
||||
"""
|
||||
pr_base = _detect_pr_base_ref()
|
||||
if pr_base is None:
|
||||
# Local-only / detached state: treat as advisory — surface a note but
|
||||
# don't block. The CI run will catch the attack.
|
||||
return True, None
|
||||
rc_mb, mb, _ = _run_git(["merge-base", pr_base, "HEAD"])
|
||||
if rc_mb != 0 or not mb:
|
||||
# Cannot compute merge-base (fork without origin?). Be conservative:
|
||||
# warn but do not block — CI on the canonical repo will catch it.
|
||||
return True, None
|
||||
mb = mb.strip()
|
||||
rel = "scripts/v3_6_7_inversion_manifest.json"
|
||||
|
||||
# Round-4 closure: scan merge-base..HEAD for ANY commit that touches the
|
||||
# manifest, regardless of whether the final HEAD bytes equal the base
|
||||
# bytes. This catches the "touch and revert" pattern where a PR commit
|
||||
# modifies the manifest + a protected block, then a later PR commit
|
||||
# reverts only the manifest.
|
||||
rc_log, log_out, log_err = _run_git([
|
||||
"log", "--format=%H", f"{mb}..HEAD", "--", rel,
|
||||
])
|
||||
if rc_log != 0:
|
||||
# Couldn't list touching commits — be loud, don't pass silently.
|
||||
return False, (
|
||||
"[ARS-V3.7.1 LINT ERROR: anti-self-baseline guard cannot list "
|
||||
f"manifest-touching commits in {mb[:12]}..HEAD: rc={rc_log} "
|
||||
f"stderr={log_err!r}]"
|
||||
)
|
||||
touching = [c for c in log_out.splitlines() if c.strip()]
|
||||
if touching:
|
||||
commits_str = ", ".join(c[:12] for c in touching[:5])
|
||||
suffix = f" (and {len(touching) - 5} more)" if len(touching) > 5 else ""
|
||||
return False, (
|
||||
"[ARS-V3.7.1 LINT ERROR: anti-self-baseline guard tripped: "
|
||||
f"v3.6.7 manifest touched by {len(touching)} commit(s) in "
|
||||
f"{mb[:12]}..HEAD: {commits_str}{suffix}. The byte-equivalence "
|
||||
"SHA gate uses the manifest's most recent modifying commit as "
|
||||
"its baseline; allowing ANY manifest touch in the PR (even one "
|
||||
"later reverted) would let the gate hash modified content "
|
||||
"against itself. Land manifest amendments in a SEPARATE PR "
|
||||
"under a v3.7+ amendment process so the next SHA gate run "
|
||||
"sees the new manifest as its baseline. "
|
||||
"(round-2 + round-4 codex P2 closure)]"
|
||||
)
|
||||
|
||||
# Defense-in-depth: also verify final HEAD bytes match base bytes.
|
||||
# If `git log` somehow under-reports touches (e.g. a corrupted history
|
||||
# or a bug in the path filter), the byte comparison still catches the
|
||||
# final-state mismatch. This is the round-2 guard, kept as backstop.
|
||||
head_path = REPO_ROOT / rel
|
||||
head_bytes = head_path.read_bytes() if head_path.exists() else None
|
||||
base_bytes, err = _read_blob_at_commit(mb, rel)
|
||||
if err is not None:
|
||||
return False, (
|
||||
"[ARS-V3.7.1 LINT ERROR: anti-self-baseline guard tripped: "
|
||||
"v3.6.7 manifest does not exist at PR base commit "
|
||||
f"{mb[:12]}. Manifest creation / re-creation is not a "
|
||||
"v3.7.1-work-PR action. Land manifest changes in a separate "
|
||||
"amendment PR (round-2 codex P2 closure)]"
|
||||
)
|
||||
if head_bytes is None:
|
||||
return False, (
|
||||
"[ARS-V3.7.1 LINT ERROR: anti-self-baseline guard tripped: "
|
||||
"v3.6.7 manifest is missing at PR HEAD but present at PR base. "
|
||||
"Deletion is not a v3.7.1-work-PR action]"
|
||||
)
|
||||
if head_bytes != base_bytes:
|
||||
return False, (
|
||||
"[ARS-V3.7.1 LINT ERROR: anti-self-baseline guard tripped: "
|
||||
"v3.6.7 manifest bytes differ between HEAD and PR base, but "
|
||||
"no commit in merge-base..HEAD lists it as a path. This is a "
|
||||
"history-shape anomaly — investigate before proceeding]"
|
||||
)
|
||||
return True, None
|
||||
|
||||
|
||||
def _strip_file_bom(file_bytes: bytes) -> bytes:
|
||||
"""Strip a UTF-8 BOM at byte 0 of the FILE, if present.
|
||||
|
||||
Per spec § Step 0 SHA normalization: "the FILE's BOM (if any) is
|
||||
excluded". This strips ONLY the file-level BOM, NOT BOMs that may
|
||||
appear later in the file (e.g. inserted right before a protected
|
||||
heading as a hidden mutation — round-8 codex P2 closure: spec
|
||||
exclusion is file-level only, so block-level BOMs must remain in
|
||||
the hashed range so heading-prefix attacks like inserting U+FEFF
|
||||
before `## PATTERN PROTECTION (v3.6.7)` are caught).
|
||||
"""
|
||||
if file_bytes.startswith(_BOM):
|
||||
return file_bytes[len(_BOM):]
|
||||
return file_bytes
|
||||
|
||||
|
||||
# Backward-compat alias for the old name used by the unit test that pins
|
||||
# BOM-stripping behaviour (test renamed in the round-8 closure commit).
|
||||
_normalize_bytes = _strip_file_bom
|
||||
|
||||
|
||||
def _extract_block_bytes(file_bytes: bytes) -> bytes | None:
|
||||
"""Extract the v3.6.7 PATTERN PROTECTION block as bytes.
|
||||
|
||||
Spec § 388 canonical range: "start at the line containing
|
||||
`## PATTERN PROTECTION (v3.6.7)` heading; end at the line before the
|
||||
next H1 / H2 / H3 heading or EOF". The `## ` heading prefix is part
|
||||
of the canonical byte range.
|
||||
|
||||
Spec § Step 0 SHA normalization: "bytes are read raw; the FILE's
|
||||
BOM (if any) is excluded". File-level BOM stripping happens BEFORE
|
||||
extraction (caller passes raw file bytes to this function); BOMs
|
||||
that appear later in the file (e.g. inserted before a protected
|
||||
heading) are NOT stripped — they're real content mutations the
|
||||
gate must detect (round-8 codex P2 closure).
|
||||
|
||||
The v3.6.7 lint's `_extract_block` finds the marker via case-
|
||||
insensitive substring match, so it starts the returned slice at
|
||||
`PATTERN...` and silently strips the `## ` (or any other) heading
|
||||
prefix. That means a mutation of `## PATTERN...` to `### PATTERN...`
|
||||
leaves the v3.6.7 lint's extracted block byte-identical, which is
|
||||
fine for v3.6.7's invariant greps but DEFEATS the v3.7.1 byte-
|
||||
equivalence gate's heading-prefix check (round-3 codex P2 closure).
|
||||
|
||||
This wrapper extends the start of the v3.6.7 extractor's range
|
||||
backward to the start of the marker's line, so the hashed bytes
|
||||
include the heading prefix exactly as the spec requires. The end
|
||||
position and termination logic are untouched, so the byte range
|
||||
stays byte-equivalent to the v3.6.7 extractor everywhere except the
|
||||
heading prefix.
|
||||
|
||||
Returns None when the marker is missing.
|
||||
"""
|
||||
# Strip file-level BOM (byte 0 only) per spec § Step 0. This is the
|
||||
# ONLY BOM-stripping point in the pipeline; block-level BOMs stay in
|
||||
# the hashed range (round-8 closure: BOM-before-heading mutation
|
||||
# must be caught).
|
||||
file_bytes = _strip_file_bom(file_bytes)
|
||||
text = file_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
# Round-10 codex P2 closure: do NOT delegate to the v3.6.7 extractor.
|
||||
# That extractor uses a substring search (`text.lower().find(marker)`),
|
||||
# so when prose before the protected block mentions
|
||||
# `PATTERN PROTECTION (v3.6.7)`, it returns the slice starting at the
|
||||
# PROSE position. Earlier rounds tried to "correct" by anchoring the
|
||||
# heading line afterward and reusing `len(block)`, but the slice
|
||||
# length still came from the prose-to-heading fragment, not the real
|
||||
# block — so the hashed range was wrong.
|
||||
#
|
||||
# Round-9 + Round-10 fix: anchor the START at the heading line, AND
|
||||
# compute the END independently by searching for the next H1/H2/H3
|
||||
# heading after the marker line (or EOF). This mirrors the v3.6.7
|
||||
# lint's heading-to-next-heading-or-EOF termination semantics, but
|
||||
# with a true heading-anchored start.
|
||||
#
|
||||
# Pattern: line start, optional indent, 1-3 `#`, whitespace, the marker
|
||||
# text. NO `\b` after the marker (ends with `)`, a non-word char).
|
||||
# `(?m)` makes `^` match line starts; `(?i)` is the v3.6.7 convention.
|
||||
heading_re = re.compile(
|
||||
r"(?im)^[ \t]*#{1,3}[ \t]+" + re.escape(V3_6_7_PROTECTION_BLOCK)
|
||||
)
|
||||
match = heading_re.search(text)
|
||||
if match is None:
|
||||
# Heading-anchored search found nothing; the marker may exist only
|
||||
# as prose (no `#` prefix). Treat as missing.
|
||||
return None
|
||||
line_start = match.start()
|
||||
|
||||
# Find block end at next H1/H2/H3 heading after the marker LINE, or EOF.
|
||||
next_heading_re = re.compile(r"(?m)^[ \t]*#{1,3}[ \t]+")
|
||||
eol = text.find("\n", match.end())
|
||||
search_start = (eol + 1) if eol >= 0 else len(text)
|
||||
next_match = next_heading_re.search(text, pos=search_start)
|
||||
block_end = next_match.start() if next_match else len(text)
|
||||
block_with_prefix = text[line_start:block_end]
|
||||
return block_with_prefix.encode("utf-8")
|
||||
|
||||
|
||||
def _read_blob_at_commit(commit: str, repo_relpath: str) -> tuple[bytes | None, str | None]:
|
||||
"""Return (raw bytes, error). Uses `git show <commit>:<path>`."""
|
||||
result = subprocess.run(
|
||||
["git", "show", f"{commit}:{repo_relpath}"],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode("utf-8", errors="replace").strip()
|
||||
return None, (
|
||||
f"[ARS-V3.7.1 LINT ERROR: `git show {commit}:{repo_relpath}` "
|
||||
f"failed: {stderr!r}]"
|
||||
)
|
||||
return result.stdout, None
|
||||
|
||||
|
||||
def _sha256(b: bytes) -> str:
|
||||
return hashlib.sha256(b).hexdigest()
|
||||
|
||||
|
||||
def _load_v3_6_7_manifest() -> tuple[list[str] | None, str | None]:
|
||||
"""Read the v3.6.7 manifest and return (file_list, error)."""
|
||||
if not V3_6_7_MANIFEST.exists():
|
||||
return None, (
|
||||
"[ARS-V3.7.1 LINT ERROR: v3.6.7 manifest missing at "
|
||||
f"{V3_6_7_MANIFEST.relative_to(REPO_ROOT)}]"
|
||||
)
|
||||
try:
|
||||
data = json.loads(V3_6_7_MANIFEST.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
return None, f"[ARS-V3.7.1 LINT ERROR: v3.6.7 manifest unreadable: {exc}]"
|
||||
files = data.get("files")
|
||||
if not isinstance(files, list) or not all(isinstance(p, str) for p in files):
|
||||
return None, (
|
||||
"[ARS-V3.7.1 LINT ERROR: v3.6.7 manifest 'files' must be a list "
|
||||
"of strings]"
|
||||
)
|
||||
return files, None
|
||||
|
||||
|
||||
def _load_v3_6_8_manifest() -> tuple[dict | None, str | None]:
|
||||
"""Read the v3.6.8 manifest. PR-1 ships an empty list; Step 3a populates."""
|
||||
if not V3_6_8_MANIFEST.exists():
|
||||
return None, (
|
||||
"[ARS-V3.7.1 LINT ERROR: v3.6.8 manifest missing at "
|
||||
f"{V3_6_8_MANIFEST.relative_to(REPO_ROOT)}]"
|
||||
)
|
||||
try:
|
||||
data = json.loads(V3_6_8_MANIFEST.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
return None, f"[ARS-V3.7.1 LINT ERROR: v3.6.8 manifest unreadable: {exc}]"
|
||||
if data.get("scope") != "v3.6.8-only":
|
||||
return None, (
|
||||
"[ARS-V3.7.1 LINT ERROR: v3.6.8 manifest 'scope' must be "
|
||||
f"'v3.6.8-only', got {data.get('scope')!r}]"
|
||||
)
|
||||
files = data.get("files")
|
||||
if not isinstance(files, list) or not all(isinstance(p, str) for p in files):
|
||||
return None, (
|
||||
"[ARS-V3.7.1 LINT ERROR: v3.6.8 manifest 'files' must be a list "
|
||||
"of strings (may be empty until Step 3a populates)]"
|
||||
)
|
||||
return data, None
|
||||
|
||||
|
||||
def check_byte_equivalence(verbose: bool = True) -> int:
|
||||
"""Run the SHA byte-equivalence gate.
|
||||
|
||||
Returns 0 on PASS, 1 on FAIL. Side effect: prints diagnostic lines to stdout.
|
||||
"""
|
||||
# 1. Shallow-clone gate (CI safety)
|
||||
err = _ensure_full_clone()
|
||||
if err is not None:
|
||||
print(err)
|
||||
return 1
|
||||
|
||||
# 2. Anti-self-baseline guard (round-2 codex P2 closure):
|
||||
# Refuse to run on PRs that mutate the v3.6.7 manifest. Without this,
|
||||
# `git log -1 -- v3_6_7_inversion_manifest.json` would resolve to the
|
||||
# PR's own commit and the SHA comparison would hash modified content
|
||||
# against itself.
|
||||
ok, err = _v3_6_7_manifest_unchanged_in_pr()
|
||||
if not ok:
|
||||
print(err)
|
||||
return 1
|
||||
|
||||
# 3. v3.6.7 base commit derivation (single source of truth)
|
||||
base_commit, err = _v3_6_7_base_commit()
|
||||
if err is not None:
|
||||
print(err)
|
||||
return 1
|
||||
if verbose:
|
||||
print(f"[v3.7.1 SHA gate] v3.6.7 base commit: {base_commit[:12]}")
|
||||
|
||||
# 3. Load v3.6.7 manifest (file list = single source of truth)
|
||||
files_v367, err = _load_v3_6_7_manifest()
|
||||
if err is not None:
|
||||
print(err)
|
||||
return 1
|
||||
if not files_v367:
|
||||
# An empty v3.6.7 manifest would be a contract violation; fail loud.
|
||||
print(
|
||||
"[ARS-V3.7.1 LINT ERROR: v3.6.7 manifest carries empty file list; "
|
||||
"expected the three v3.6.7-frozen agent files]"
|
||||
)
|
||||
return 1
|
||||
|
||||
# 4. Load v3.6.8 manifest (just for shape validation; entries unused here)
|
||||
_, err = _load_v3_6_8_manifest()
|
||||
if err is not None:
|
||||
print(err)
|
||||
return 1
|
||||
|
||||
# 5. For each v3.6.7 protected file: extract block at HEAD and at base
|
||||
# commit, hash both, assert equality.
|
||||
failures: list[str] = []
|
||||
for rel in files_v367:
|
||||
head_path = REPO_ROOT / rel
|
||||
if not head_path.exists():
|
||||
failures.append(
|
||||
f" [{rel}] missing at PR HEAD (deletion of v3.6.7-protected "
|
||||
"file would re-open v3.6.7 convergence; restore the file)"
|
||||
)
|
||||
continue
|
||||
head_bytes_full = head_path.read_bytes()
|
||||
head_block = _extract_block_bytes(head_bytes_full)
|
||||
if head_block is None:
|
||||
failures.append(
|
||||
f" [{rel}] PATTERN PROTECTION (v3.6.7) marker missing at "
|
||||
"PR HEAD (the v3.6.7-tagged block was renamed or removed; "
|
||||
"boundary rule violated — v3.7.1 must NOT mutate v3.6.7 blocks)"
|
||||
)
|
||||
continue
|
||||
base_bytes_full, err = _read_blob_at_commit(base_commit, rel)
|
||||
if err is not None:
|
||||
failures.append(f" [{rel}] {err}")
|
||||
continue
|
||||
base_block = _extract_block_bytes(base_bytes_full)
|
||||
if base_block is None:
|
||||
failures.append(
|
||||
f" [{rel}] PATTERN PROTECTION (v3.6.7) marker missing at "
|
||||
f"v3.6.7 base commit {base_commit[:12]} — manifest "
|
||||
"derivation produced an inconsistent base"
|
||||
)
|
||||
continue
|
||||
head_sha = _sha256(head_block)
|
||||
base_sha = _sha256(base_block)
|
||||
if head_sha != base_sha:
|
||||
failures.append(
|
||||
f" [{rel}] BYTE-EQUIVALENCE FAIL\n"
|
||||
f" HEAD SHA-256: {head_sha}\n"
|
||||
f" v3.6.7 SHA-256: {base_sha}\n"
|
||||
f" v3.6.7-tagged PATTERN PROTECTION block changed; "
|
||||
f"v3.7.1 boundary rule violated. Restore the block or land "
|
||||
f"a v3.6.7+ amendment manifest first."
|
||||
)
|
||||
elif verbose:
|
||||
print(f" [{rel}] PASS (sha256={head_sha[:12]})")
|
||||
|
||||
if failures:
|
||||
print("[ARS-V3.7.1 LINT ERROR: v3.6.7 PATTERN PROTECTION block byte-equivalence failures]")
|
||||
for line in failures:
|
||||
print(line)
|
||||
return 1
|
||||
if verbose:
|
||||
print(f"[v3.7.1 SHA gate] PASSED ({len(files_v367)} v3.6.7 protected file(s))")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
return check_byte_equivalence()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -35,7 +35,11 @@ MARKERS = {
|
||||
def _short_type(prop_def: dict) -> str:
|
||||
t = prop_def.get("type")
|
||||
if isinstance(t, list):
|
||||
return " | ".join(sorted(x for x in t if x))
|
||||
# Markdown-table cells are pipe-delimited, so a JSON Schema
|
||||
# union type rendered as `null | string` would split into extra
|
||||
# columns. Escape the separator with `\|` so GitHub renders it
|
||||
# as a literal pipe inside the cell.
|
||||
return r" \| ".join(sorted(x for x in t if x))
|
||||
if t:
|
||||
return t
|
||||
if "oneOf" in prop_def:
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"""Tests for ARS v3.7.1 trust-chain frontmatter lint (Step 1 of v3.7.1 impl).
|
||||
|
||||
Spec: docs/design/2026-04-30-ars-v3.6.8-trust-provenance-and-drift-transparency-spec.md
|
||||
§ 3.1 D1, § Step 1
|
||||
|
||||
Each spec firm rule gets at least one positive (valid combination passes)
|
||||
and one negative (deliberately-violated combination fails) test. JSON
|
||||
Schema validation runs alongside the lint to confirm defense-in-depth:
|
||||
schema-side `allOf` branches and lint-side rule checks both reject.
|
||||
|
||||
Per the user's iron law: positive + negative tests for every rule.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from scripts.check_v3_6_8_frontmatter_trust_schema import (
|
||||
check_entry,
|
||||
check_payload,
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
ENTRY_SCHEMA_PATH = REPO_ROOT / "shared" / "contracts" / "passport" / "literature_corpus_entry.schema.json"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def schema() -> dict[str, Any]:
|
||||
with ENTRY_SCHEMA_PATH.open(encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def validator(schema: dict[str, Any]) -> Draft202012Validator:
|
||||
Draft202012Validator.check_schema(schema)
|
||||
return Draft202012Validator(schema)
|
||||
|
||||
|
||||
def _minimal_entry(**overrides: Any) -> dict[str, Any]:
|
||||
"""Smallest valid v3.6.4 entry; tests overlay trust fields on top."""
|
||||
base = {
|
||||
"citation_key": "smith2024",
|
||||
"title": "Sample title",
|
||||
"authors": [{"family": "Smith", "given": "Alex"}],
|
||||
"year": 2024,
|
||||
"source_pointer": "file:///fixture/smith2024.pdf",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
# ---------- Schema self-consistency ----------
|
||||
|
||||
|
||||
def test_schema_is_valid_draft_2020_12(schema: dict[str, Any]) -> None:
|
||||
"""The schema itself must be a valid JSON Schema 2020-12 document."""
|
||||
Draft202012Validator.check_schema(schema)
|
||||
|
||||
|
||||
def test_schema_includes_seven_v3_7_1_trust_fields(schema: dict[str, Any]) -> None:
|
||||
"""Spec § Step 1 requires exactly seven entry-stored trust fields."""
|
||||
expected = {
|
||||
"source_acquired",
|
||||
"source_acquisition_date",
|
||||
"source_acquisition_path",
|
||||
"source_verified_against_original",
|
||||
"source_verification_method",
|
||||
"description_source",
|
||||
"description_last_audit",
|
||||
}
|
||||
assert expected.issubset(schema["properties"].keys()), (
|
||||
f"Missing trust fields: {expected - schema['properties'].keys()}"
|
||||
)
|
||||
# Must NOT add human_read_source / human_read_at to entry schema
|
||||
# (per spec §3.1 firm rule #3 + §3.6 firm rule #1).
|
||||
assert "human_read_source" not in schema["properties"]
|
||||
assert "human_read_at" not in schema["properties"]
|
||||
|
||||
|
||||
def test_schema_keeps_additional_properties_false(schema: dict[str, Any]) -> None:
|
||||
assert schema.get("additionalProperties") is False, (
|
||||
"additionalProperties: false is the contract that prevents adapters / "
|
||||
"consumer agents from sneaking human_read_* fields onto entries."
|
||||
)
|
||||
|
||||
|
||||
# ---------- Rule #1 — verified=true preconditions ----------
|
||||
|
||||
|
||||
def test_rule1_verified_true_with_acquired_true_and_valid_method_passes(validator) -> None:
|
||||
entry = _minimal_entry(
|
||||
source_acquired=True,
|
||||
source_verified_against_original=True,
|
||||
source_verification_method="codex_audit",
|
||||
)
|
||||
assert list(validator.iter_errors(entry)) == []
|
||||
assert check_entry(entry, "smith2024") == []
|
||||
|
||||
|
||||
def test_rule1_verified_true_without_acquired_fails(validator) -> None:
|
||||
entry = _minimal_entry(
|
||||
source_acquired=False,
|
||||
source_verified_against_original=True,
|
||||
source_verification_method="codex_audit",
|
||||
)
|
||||
# Schema-side: rule #1 allOf branch fires
|
||||
assert any(validator.iter_errors(entry))
|
||||
# Lint-side: friendly diagnostic
|
||||
errors = check_entry(entry, "smith2024")
|
||||
assert any("Rule #1 violated" in e and "source_acquired=true" in e for e in errors)
|
||||
|
||||
|
||||
def test_rule1_verified_true_with_method_none_fails(validator) -> None:
|
||||
"""Round-2 R2-007 amend: 'none' is enumerated but FORBIDDEN with verified=true."""
|
||||
entry = _minimal_entry(
|
||||
source_acquired=True,
|
||||
source_verified_against_original=True,
|
||||
source_verification_method="none",
|
||||
)
|
||||
assert any(validator.iter_errors(entry))
|
||||
errors = check_entry(entry, "smith2024")
|
||||
assert any("Rule #1" in e and "'none'" in e for e in errors)
|
||||
|
||||
|
||||
def test_rule1_verified_true_missing_method_fails(validator) -> None:
|
||||
entry = _minimal_entry(
|
||||
source_acquired=True,
|
||||
source_verified_against_original=True,
|
||||
# source_verification_method intentionally omitted
|
||||
)
|
||||
# Schema fires because allOf branch sets `required: [source_acquired, source_verification_method]`
|
||||
assert any(validator.iter_errors(entry))
|
||||
errors = check_entry(entry, "smith2024")
|
||||
assert any("Rule #1" in e and "source_verification_method" in e for e in errors)
|
||||
|
||||
|
||||
def test_rule1_verified_false_does_not_constrain_method(validator) -> None:
|
||||
"""When verified=false, method='none' is fine.
|
||||
|
||||
Rule #2 still applies (source_acquired=false REQUIRES description_last_audit
|
||||
to be present + null/'none'); we satisfy it here so the test isolates Rule #1.
|
||||
"""
|
||||
entry = _minimal_entry(
|
||||
source_acquired=False,
|
||||
source_verified_against_original=False,
|
||||
source_verification_method="none",
|
||||
description_last_audit="none", # Rule #2 strict-REQUIRES presence
|
||||
)
|
||||
assert list(validator.iter_errors(entry)) == []
|
||||
assert check_entry(entry, "smith2024") == []
|
||||
|
||||
|
||||
# ---------- Rule #2 — source_acquired=false → description_last_audit ∈ {null, 'none'} ----------
|
||||
|
||||
|
||||
def test_rule2_acquired_false_with_audit_none_passes(validator) -> None:
|
||||
entry = _minimal_entry(
|
||||
source_acquired=False,
|
||||
description_last_audit="none",
|
||||
)
|
||||
assert list(validator.iter_errors(entry)) == []
|
||||
assert check_entry(entry, "smith2024") == []
|
||||
|
||||
|
||||
def test_rule2_acquired_false_with_audit_null_fails(validator) -> None:
|
||||
"""Round-6 codex P2 closure: spec §3.1 firm rule #2 says REQUIRES
|
||||
description_last_audit: 'none' (literal sentinel). Spec yaml at line 111
|
||||
lists the value vocabulary as `<round_id> | none` with no null alternative.
|
||||
null in the rule-#2 case must be rejected by both schema and lint.
|
||||
"""
|
||||
entry = _minimal_entry(
|
||||
source_acquired=False,
|
||||
description_last_audit=None,
|
||||
)
|
||||
schema_errs = list(validator.iter_errors(entry))
|
||||
assert schema_errs, (
|
||||
"Schema must reject source_acquired=false + description_last_audit=null "
|
||||
"(round-6 closure: only literal 'none' is allowed)"
|
||||
)
|
||||
lint_errs = check_entry(entry, "smith2024")
|
||||
assert any(
|
||||
"Rule #2" in e and "literal sentinel string" in e for e in lint_errs
|
||||
), f"Lint must surface literal-only enforcement; got: {lint_errs}"
|
||||
|
||||
|
||||
def test_rule2_acquired_false_with_real_audit_round_fails(validator) -> None:
|
||||
entry = _minimal_entry(
|
||||
source_acquired=False,
|
||||
description_last_audit="round-3-codex",
|
||||
)
|
||||
assert any(validator.iter_errors(entry))
|
||||
errors = check_entry(entry, "smith2024")
|
||||
assert any("Rule #2" in e and "round-3-codex" in e for e in errors)
|
||||
|
||||
|
||||
def test_rule2_acquired_false_with_missing_audit_field_fails(validator) -> None:
|
||||
"""Round-1 codex P2 closure: REQUIRES is strict — the field MUST be present.
|
||||
|
||||
Schema-side `then.required` enforces this; lint-side mirrors with a
|
||||
friendly 'field is missing' diagnostic.
|
||||
"""
|
||||
entry = _minimal_entry(source_acquired=False)
|
||||
# description_last_audit deliberately omitted
|
||||
assert "description_last_audit" not in entry
|
||||
schema_errs = list(validator.iter_errors(entry))
|
||||
assert schema_errs, (
|
||||
"Schema must reject source_acquired=false with missing "
|
||||
"description_last_audit (Rule #2 REQUIRES is strict)"
|
||||
)
|
||||
lint_errs = check_entry(entry, "smith2024")
|
||||
assert any(
|
||||
"Rule #2" in e and "is missing" in e for e in lint_errs
|
||||
), f"Lint must surface missing-field violation; got: {lint_errs}"
|
||||
|
||||
|
||||
def test_description_source_accepts_arbitrary_bibliography_revision(validator) -> None:
|
||||
"""Round-1 codex P2 closure: spec § 3.1 yaml uses `bibliography_v<n>` as a
|
||||
template (any non-negative integer n), not a hard-coded enum of v1..v3.
|
||||
A revision number above the initial release range must validate.
|
||||
"""
|
||||
for v in ["bibliography_v0", "bibliography_v4", "bibliography_v17", "bibliography_v999"]:
|
||||
entry = _minimal_entry(description_source=v)
|
||||
assert list(validator.iter_errors(entry)) == [], (
|
||||
f"description_source={v!r} should validate against the "
|
||||
f"`bibliography_v<n>` template"
|
||||
)
|
||||
|
||||
|
||||
def test_description_source_still_accepts_canonical_values(validator) -> None:
|
||||
"""Sanity: the original_pdf / secondary_summary canonical values keep working."""
|
||||
for v in ["original_pdf", "secondary_summary", "bibliography_v1"]:
|
||||
entry = _minimal_entry(description_source=v)
|
||||
assert list(validator.iter_errors(entry)) == [], (
|
||||
f"description_source={v!r} (canonical) must validate"
|
||||
)
|
||||
|
||||
|
||||
def test_description_source_rejects_unrelated_strings(validator) -> None:
|
||||
"""The pattern is anchored — typos and unrelated strings still fail."""
|
||||
for v in ["bib_v1", "bibliography_vX", "bibliography", "other"]:
|
||||
entry = _minimal_entry(description_source=v)
|
||||
assert list(validator.iter_errors(entry)), (
|
||||
f"description_source={v!r} should be rejected by the pattern"
|
||||
)
|
||||
|
||||
|
||||
def test_rule2_acquired_true_with_real_audit_round_passes(validator) -> None:
|
||||
"""When source_acquired=true, any audit round id is fine."""
|
||||
entry = _minimal_entry(
|
||||
source_acquired=True,
|
||||
source_verified_against_original=False, # Rule #1 allows this
|
||||
source_verification_method="none",
|
||||
description_last_audit="round-3-codex",
|
||||
)
|
||||
assert list(validator.iter_errors(entry)) == []
|
||||
assert check_entry(entry, "smith2024") == []
|
||||
|
||||
|
||||
# ---------- Rule #3 — no literal human_read_* on entry ----------
|
||||
|
||||
|
||||
def test_rule3_literal_human_read_source_rejected_by_schema(validator) -> None:
|
||||
"""additionalProperties: false catches this at the schema layer."""
|
||||
entry = _minimal_entry(human_read_source=True)
|
||||
errs = list(validator.iter_errors(entry))
|
||||
assert errs, "schema must reject literal human_read_source via additionalProperties: false"
|
||||
assert any("human_read_source" in str(e.message) for e in errs)
|
||||
|
||||
|
||||
def test_rule3_literal_human_read_source_rejected_by_lint() -> None:
|
||||
"""Lint emits a spec-cited friendly message in addition to schema rejection."""
|
||||
entry = _minimal_entry(human_read_source=True)
|
||||
errors = check_entry(entry, "smith2024")
|
||||
assert any(
|
||||
"Rule #3" in e and "human_read_source" in e and "§3.6 peer file" in e
|
||||
for e in errors
|
||||
)
|
||||
|
||||
|
||||
def test_rule3_literal_human_read_at_rejected(validator) -> None:
|
||||
"""The 'derived at read-time' contract covers human_read_at as well."""
|
||||
entry = _minimal_entry(human_read_at="2026-05-07T00:00:00Z")
|
||||
assert any(validator.iter_errors(entry))
|
||||
errors = check_entry(entry, "smith2024")
|
||||
assert any("Rule #3" in e and "human_read_at" in e for e in errors)
|
||||
|
||||
|
||||
# ---------- Payload-shape coverage ----------
|
||||
|
||||
|
||||
def test_check_payload_handles_passport_shape() -> None:
|
||||
payload = {
|
||||
"literature_corpus": [
|
||||
_minimal_entry(citation_key="ok2024"),
|
||||
_minimal_entry(citation_key="bad2024", human_read_source=True),
|
||||
]
|
||||
}
|
||||
failures = check_payload(payload, "<test>")
|
||||
assert any("bad2024" in f for f in failures)
|
||||
assert all("ok2024" not in f or "Rule" not in f for f in failures)
|
||||
|
||||
|
||||
def test_check_payload_handles_bare_entry() -> None:
|
||||
entry = _minimal_entry(human_read_source=True)
|
||||
failures = check_payload(entry, "<test>")
|
||||
assert any("Rule #3" in f for f in failures)
|
||||
|
||||
|
||||
def test_check_payload_handles_bare_entry_list() -> None:
|
||||
payload = [_minimal_entry(citation_key="x", human_read_source=True)]
|
||||
failures = check_payload(payload, "<test>")
|
||||
assert any("Rule #3" in f for f in failures)
|
||||
|
||||
|
||||
def test_check_payload_clean_passport_returns_empty() -> None:
|
||||
payload = {"literature_corpus": [_minimal_entry()]}
|
||||
assert check_payload(payload, "<test>") == []
|
||||
|
||||
|
||||
# ---------- Existing fixtures stay green ----------
|
||||
|
||||
|
||||
def test_existing_v3_6_4_fixtures_still_pass() -> None:
|
||||
"""v3.7.1 schema must be backward-compatible with v3.6.4 adapter fixtures
|
||||
(they don't carry trust fields; absence is allowed)."""
|
||||
import yaml
|
||||
examples_root = REPO_ROOT / "scripts" / "adapters" / "examples"
|
||||
fixtures = list(examples_root.rglob("expected_passport.yaml"))
|
||||
assert fixtures, "fixture set unexpectedly empty"
|
||||
for path in fixtures:
|
||||
with path.open(encoding="utf-8") as f:
|
||||
payload = yaml.safe_load(f)
|
||||
failures = check_payload(payload, str(path.relative_to(REPO_ROOT)))
|
||||
assert failures == [], (
|
||||
f"v3.6.4 fixture {path.relative_to(REPO_ROOT)} must remain valid "
|
||||
f"under v3.7.1 schema; got: {failures}"
|
||||
)
|
||||
@@ -0,0 +1,567 @@
|
||||
"""Mutation tests for ARS v3.7.1 byte-equivalence SHA gate.
|
||||
|
||||
Spec: docs/design/2026-04-30-ars-v3.6.8-trust-provenance-and-drift-transparency-spec.md
|
||||
§ Step 0 — Lint manifest separation (round-1 codex F-004 amend)
|
||||
|
||||
Tests verify that:
|
||||
1. Happy path: untouched v3.6.7 PATTERN PROTECTION blocks pass.
|
||||
2. Mutation: any byte change inside a v3.6.7-tagged block fails.
|
||||
3. Additive boundary: edits OUTSIDE v3.6.7-tagged blocks (e.g. appending
|
||||
a new "Two-Layer Citation Emission" section after the block) do NOT
|
||||
trigger SHA mismatch.
|
||||
4. v3.6.8 manifest shape validation (scope tag, files list).
|
||||
5. PR-1 expected state (v3.6.8 manifest with empty 'files' list) is OK.
|
||||
6. Boundary errors (v3.6.7 marker missing at HEAD; manifest absent).
|
||||
|
||||
The lint runs git operations against the actual repo, so each mutation
|
||||
test backs up the file under test, mutates, runs the lint as a subprocess,
|
||||
and restores the file in `finally` to keep the working tree clean.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
LINT = REPO_ROOT / "scripts" / "check_v3_6_8_pattern_protection.py"
|
||||
V3_6_7_MANIFEST = REPO_ROOT / "scripts" / "v3_6_7_inversion_manifest.json"
|
||||
V3_6_8_MANIFEST = REPO_ROOT / "scripts" / "v3_6_8_inversion_manifest.json"
|
||||
|
||||
# v3.6.7-protected agent files. We pick synthesis_agent.md as the canonical
|
||||
# mutation target throughout; the lint hashes all three so mutating any one
|
||||
# proves the gate works against the full manifest.
|
||||
TARGET_AGENT = REPO_ROOT / "deep-research" / "agents" / "synthesis_agent.md"
|
||||
PROTECTION_MARKER = "## PATTERN PROTECTION (v3.6.7)"
|
||||
|
||||
|
||||
def _run_lint() -> subprocess.CompletedProcess[str]:
|
||||
"""Run the v3.6.8 lint as a subprocess (so its sys.exit propagates)."""
|
||||
return subprocess.run(
|
||||
[sys.executable, str(LINT)],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
# ---------- Helpers for mutation + restore (file-level snapshot) ----------
|
||||
|
||||
|
||||
class _Snapshot:
|
||||
"""Backs up a file's bytes; restores on context exit."""
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
self._bytes: bytes | None = None
|
||||
self._existed: bool = False
|
||||
|
||||
def __enter__(self) -> "_Snapshot":
|
||||
self._existed = self.path.exists()
|
||||
if self._existed:
|
||||
self._bytes = self.path.read_bytes()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
if self._existed and self._bytes is not None:
|
||||
self.path.write_bytes(self._bytes)
|
||||
elif not self._existed and self.path.exists():
|
||||
self.path.unlink()
|
||||
|
||||
|
||||
# ---------- Tests ----------
|
||||
|
||||
|
||||
def test_happy_path_passes_on_clean_tree() -> None:
|
||||
"""Untouched v3.6.7 blocks → SHA gate passes."""
|
||||
result = _run_lint()
|
||||
assert result.returncode == 0, (
|
||||
f"Expected exit 0 on clean tree, got {result.returncode}.\n"
|
||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
assert "PASSED" in result.stdout
|
||||
# All three v3.6.7-protected files reported.
|
||||
assert "synthesis_agent.md" in result.stdout
|
||||
assert "research_architect_agent.md" in result.stdout
|
||||
assert "report_compiler_agent.md" in result.stdout
|
||||
|
||||
|
||||
def test_mutation_inside_v3_6_7_block_fails() -> None:
|
||||
"""Inject 1 byte inside the v3.6.7 PATTERN PROTECTION block → exit 1 + FAIL diagnostic."""
|
||||
with _Snapshot(TARGET_AGENT):
|
||||
text = TARGET_AGENT.read_text(encoding="utf-8")
|
||||
pos = text.find(PROTECTION_MARKER)
|
||||
assert pos != -1, "marker missing in test fixture (test would be vacuous)"
|
||||
# Inject a stray space at end of the marker line (still inside block).
|
||||
nl = text.index("\n", pos)
|
||||
mutated = text[:nl] + " " + text[nl:]
|
||||
TARGET_AGENT.write_text(mutated, encoding="utf-8")
|
||||
|
||||
result = _run_lint()
|
||||
assert result.returncode == 1
|
||||
assert "BYTE-EQUIVALENCE FAIL" in result.stdout
|
||||
assert "synthesis_agent.md" in result.stdout
|
||||
assert "v3.7.1 boundary rule violated" in result.stdout
|
||||
|
||||
|
||||
def test_appending_new_h2_directly_after_eof_newline_passes() -> None:
|
||||
"""Append a new H2 directly after the file's trailing newline → SHA gate passes.
|
||||
|
||||
Boundary rule (spec §388): v3.7.1 MAY add new prompt sections OUTSIDE
|
||||
the v3.6.7 PATTERN PROTECTION block. When the v3.6.7 block runs to EOF
|
||||
(the case for all three current manifest files), the appended H2 must
|
||||
be placed IMMEDIATELY after the file's trailing newline — no extra
|
||||
blank line — so the heading-based extractor's range stays byte-equal
|
||||
to the base commit's range. (The extractor terminates at the next
|
||||
H1/H2/H3 line; the bytes inside the range are file[marker_pos:next_h_line].
|
||||
Inserting a blank line between EOF and the new H2 would extend the
|
||||
extracted range by those blank-line bytes and trigger SHA mismatch.)
|
||||
|
||||
This test pins the contract for Step 3a's "Two-Layer Citation Emission"
|
||||
section addition: append directly, no blank-line separator.
|
||||
"""
|
||||
with _Snapshot(TARGET_AGENT):
|
||||
text = TARGET_AGENT.read_text(encoding="utf-8")
|
||||
# File already ends with a trailing newline; append H2 immediately.
|
||||
# NO leading "\n\n" — that would expand the v3.6.7 block range.
|
||||
assert text.endswith("\n"), "fixture assumption (file ends with newline) violated"
|
||||
appended = text + "## Two-Layer Citation Emission (v3.7.1 placeholder)\n\nbody\n"
|
||||
TARGET_AGENT.write_text(appended, encoding="utf-8")
|
||||
result = _run_lint()
|
||||
assert result.returncode == 0, (
|
||||
f"Appending H2 directly after EOF newline must keep byte-"
|
||||
f"equivalence; lint should PASS.\n"
|
||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
|
||||
|
||||
def test_appending_new_h2_with_blank_line_separator_fails() -> None:
|
||||
"""Adding a blank-line separator before the new H2 → SHA mismatch.
|
||||
|
||||
This is the dual of the test above: it pins the failure mode that
|
||||
Step 3a's section-addition contract must avoid. If a contributor
|
||||
accidentally adds `\\n\\n## New Section` (the natural Markdown idiom)
|
||||
after the v3.6.7 block, the byte-equivalence gate catches it. Step 3a's
|
||||
documentation will instruct contributors to elide the blank line for
|
||||
EOF-terminating PATTERN PROTECTION blocks.
|
||||
"""
|
||||
with _Snapshot(TARGET_AGENT):
|
||||
text = TARGET_AGENT.read_text(encoding="utf-8")
|
||||
# The natural Markdown idiom — blank line then H2 — must FAIL when
|
||||
# the v3.6.7 block runs to EOF, because the blank line bytes get
|
||||
# absorbed into the extractor's range.
|
||||
appended = text + "\n## Two-Layer Citation Emission (v3.7.1 placeholder)\n\nbody\n"
|
||||
TARGET_AGENT.write_text(appended, encoding="utf-8")
|
||||
result = _run_lint()
|
||||
assert result.returncode == 1, (
|
||||
"Adding a blank-line separator before the new H2 must trigger "
|
||||
"SHA mismatch (the blank line bytes fall inside the EOF-terminating "
|
||||
"extractor range). If this test fails, the contract for Step 3a "
|
||||
"section additions has weakened and v3.7.1 boundary rule is at risk."
|
||||
)
|
||||
assert "BYTE-EQUIVALENCE FAIL" in result.stdout
|
||||
|
||||
|
||||
def test_v3_6_8_manifest_scope_must_be_correct() -> None:
|
||||
"""Wrong scope tag → lint refuses to run (clear error)."""
|
||||
with _Snapshot(V3_6_8_MANIFEST):
|
||||
data = json.loads(V3_6_8_MANIFEST.read_text(encoding="utf-8"))
|
||||
data["scope"] = "v3.6.7-only" # wrong — this is v3.6.8 manifest
|
||||
V3_6_8_MANIFEST.write_text(json.dumps(data), encoding="utf-8")
|
||||
result = _run_lint()
|
||||
assert result.returncode == 1
|
||||
assert "v3.6.8-only" in result.stdout
|
||||
assert "scope" in result.stdout
|
||||
|
||||
|
||||
def test_v3_6_8_manifest_files_must_be_list() -> None:
|
||||
"""'files' as non-list → reject."""
|
||||
with _Snapshot(V3_6_8_MANIFEST):
|
||||
V3_6_8_MANIFEST.write_text(
|
||||
json.dumps({"scope": "v3.6.8-only", "files": "not-a-list"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = _run_lint()
|
||||
assert result.returncode == 1
|
||||
assert "list of strings" in result.stdout
|
||||
|
||||
|
||||
def test_pr1_initial_state_empty_files_list_is_ok() -> None:
|
||||
"""PR-1 ships v3.6.8 manifest with files: [] until Step 3a populates."""
|
||||
with _Snapshot(V3_6_8_MANIFEST):
|
||||
V3_6_8_MANIFEST.write_text(
|
||||
json.dumps({"scope": "v3.6.8-only", "files": []}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = _run_lint()
|
||||
assert result.returncode == 0, (
|
||||
f"Empty v3.6.8 'files' list is the expected PR-1 state and must "
|
||||
f"NOT block the lint.\nstdout:\n{result.stdout}"
|
||||
)
|
||||
|
||||
|
||||
def test_v3_6_7_manifest_deletion_hard_fails() -> None:
|
||||
"""v3.6.7 manifest is the source of truth. Missing it → hard error.
|
||||
|
||||
After the round-2 anti-self-baseline guard, deletion is caught earlier:
|
||||
the guard's HEAD-vs-base comparison sees the file missing at HEAD but
|
||||
present at the PR base and rejects with a deletion-specific message.
|
||||
The guard message is more precise than the legacy "manifest missing"
|
||||
bare error, so this test just asserts a hard failure with a v3.7.1 lint
|
||||
error that mentions the manifest.
|
||||
"""
|
||||
with _Snapshot(V3_6_7_MANIFEST):
|
||||
V3_6_7_MANIFEST.unlink()
|
||||
result = _run_lint()
|
||||
assert result.returncode == 1
|
||||
# Either the guard catches it ("missing at PR HEAD") or the inner
|
||||
# loader catches it ("v3.6.7 manifest missing"); both are correct.
|
||||
assert (
|
||||
"v3.6.7 manifest" in result.stdout
|
||||
and ("missing" in result.stdout or "guard" in result.stdout)
|
||||
), f"Expected manifest-missing error; got: {result.stdout}"
|
||||
|
||||
|
||||
def test_v3_6_8_manifest_deletion_hard_fails() -> None:
|
||||
"""Missing v3.6.8 manifest → hard error (lint configuration broken)."""
|
||||
with _Snapshot(V3_6_8_MANIFEST):
|
||||
V3_6_8_MANIFEST.unlink()
|
||||
result = _run_lint()
|
||||
assert result.returncode == 1
|
||||
assert "v3.6.8 manifest missing" in result.stdout
|
||||
|
||||
|
||||
def test_heading_prefix_mutation_is_caught() -> None:
|
||||
"""Round-3 codex P2 closure: spec § 388 says the canonical byte range
|
||||
starts at the LINE containing `## PATTERN PROTECTION (v3.6.7)`, so the
|
||||
`## ` heading prefix is part of the hashed bytes.
|
||||
|
||||
The v3.6.7 lint's underlying `_extract_block` does case-insensitive
|
||||
substring search for the marker text and returns a slice starting at
|
||||
`PATTERN...` — silently dropping the heading prefix. That's fine for
|
||||
v3.6.7's invariant greps, but it would let the v3.7.1 SHA gate accept
|
||||
a `## → ### ` mutation as byte-equivalent.
|
||||
|
||||
This test mutates `## PATTERN PROTECTION (v3.6.7)` to
|
||||
`### PATTERN PROTECTION (v3.6.7)` and asserts the gate FAILS. The
|
||||
v3.6.8 lint extends the extractor's start position backward to the
|
||||
start of the marker's line specifically to close this gap.
|
||||
"""
|
||||
with _Snapshot(TARGET_AGENT):
|
||||
text = TARGET_AGENT.read_text(encoding="utf-8")
|
||||
mutated = text.replace(
|
||||
"## PATTERN PROTECTION (v3.6.7)",
|
||||
"### PATTERN PROTECTION (v3.6.7)",
|
||||
1,
|
||||
)
|
||||
assert mutated != text, "heading-mutation fixture failed to apply"
|
||||
TARGET_AGENT.write_text(mutated, encoding="utf-8")
|
||||
result = _run_lint()
|
||||
assert result.returncode == 1, (
|
||||
"Heading-prefix mutation must be caught by the SHA gate "
|
||||
"(round-3 codex P2 closure)."
|
||||
)
|
||||
assert "BYTE-EQUIVALENCE FAIL" in result.stdout
|
||||
|
||||
|
||||
def test_extractor_includes_heading_prefix_bytes() -> None:
|
||||
"""Verify the v3.6.8 extractor wraps the v3.6.7 extractor with line-start
|
||||
backtracking so heading prefix bytes are in the hashed range.
|
||||
"""
|
||||
from scripts.check_v3_6_8_pattern_protection import _extract_block_bytes
|
||||
h2_bytes_in = "prelude\n\n## PATTERN PROTECTION (v3.6.7)\n\nbody1\n".encode("utf-8")
|
||||
h3_bytes_in = "prelude\n\n### PATTERN PROTECTION (v3.6.7)\n\nbody1\n".encode("utf-8")
|
||||
h2_bytes = _extract_block_bytes(h2_bytes_in)
|
||||
h3_bytes = _extract_block_bytes(h3_bytes_in)
|
||||
assert h2_bytes is not None and h3_bytes is not None
|
||||
# The extractor must distinguish H2 vs H3 in its returned bytes.
|
||||
assert h2_bytes != h3_bytes, (
|
||||
"heading prefix must be inside the byte range; H2 vs H3 "
|
||||
"should produce different SHAs"
|
||||
)
|
||||
# And the prefix bytes must literally be present.
|
||||
assert h2_bytes.startswith(b"## PATTERN")
|
||||
assert h3_bytes.startswith(b"### PATTERN")
|
||||
|
||||
|
||||
def test_prose_mention_does_not_truncate_block_range() -> None:
|
||||
"""Round-10 codex P2 closure: not only must the START be heading-anchored
|
||||
(round-9 closure), the END must also come from an independent search
|
||||
after the heading line — not from the v3.6.7 legacy extractor's
|
||||
substring-anchored slice length.
|
||||
|
||||
Scenario: prose mention BEFORE the heading mentions the marker. The
|
||||
pre-round-10 implementation took `block` from the v3.6.7 extractor
|
||||
(which used substring search, latching onto the prose mention), then
|
||||
used `len(block)` as the slice length from the heading position. That
|
||||
`len(block)` equalled "from prose to next heading", which (when added
|
||||
to the heading line_start) covered the WRONG byte range — could be
|
||||
truncated or could overshoot, depending on relative offsets.
|
||||
|
||||
This test pins the correct behaviour: the extracted block bytes must
|
||||
be the bytes from the heading line through the next heading (or EOF),
|
||||
inclusive of the heading prefix, regardless of whether prose mentions
|
||||
appear earlier in the file.
|
||||
"""
|
||||
from scripts.check_v3_6_8_pattern_protection import _extract_block_bytes
|
||||
text = (
|
||||
"## Two-Layer Citation Emission (v3.7.1)\n"
|
||||
"\n"
|
||||
"This relates to the existing PATTERN PROTECTION (v3.6.7) block.\n"
|
||||
"\n"
|
||||
"## PATTERN PROTECTION (v3.6.7)\n"
|
||||
"\n"
|
||||
"real block body line 1\n"
|
||||
"real block body line 2\n"
|
||||
).encode("utf-8")
|
||||
block = _extract_block_bytes(text)
|
||||
assert block is not None
|
||||
# The block must START at the real heading line.
|
||||
assert block.startswith(b"## PATTERN PROTECTION (v3.6.7)\n"), (
|
||||
f"Round-10 anchor broken; block doesn't start at heading: {block!r}"
|
||||
)
|
||||
# The block must contain the FULL real body, not a truncated fragment.
|
||||
assert b"real block body line 1" in block
|
||||
assert b"real block body line 2" in block
|
||||
# And the prose paragraph from the v3.7.1 section must NOT be inside.
|
||||
assert b"This relates to" not in block
|
||||
assert b"Two-Layer Citation Emission" not in block
|
||||
|
||||
|
||||
def test_prose_mention_of_marker_does_not_misanchor_extractor() -> None:
|
||||
"""Round-9 codex P3 closure: anchor the marker search to a Markdown
|
||||
heading line, not a free substring.
|
||||
|
||||
A v3.7.1 PR may legitimately add prose BEFORE the protected block that
|
||||
mentions `PATTERN PROTECTION (v3.6.7)` — e.g. in a "Two-Layer Citation
|
||||
Emission" section's introductory paragraph that explains how the new
|
||||
invariants relate to the v3.6.7 PATTERN PROTECTION block. The pre-
|
||||
round-9 substring search would have matched the prose mention first,
|
||||
hashed the wrong byte range, and false-failed CI on a valid edit.
|
||||
|
||||
This test verifies that an extractor invocation against text containing
|
||||
a prose mention of the marker before the actual heading still returns
|
||||
the heading-anchored block.
|
||||
"""
|
||||
from scripts.check_v3_6_8_pattern_protection import _extract_block_bytes
|
||||
text = (
|
||||
"## Two-Layer Citation Emission (v3.7.1)\n"
|
||||
"\n"
|
||||
"This section relates to the existing PATTERN PROTECTION (v3.6.7) "
|
||||
"block by extending its invariant set. Note that the prose mention "
|
||||
"above must NOT misanchor the v3.7.1 SHA gate's extractor.\n"
|
||||
"\n"
|
||||
"## PATTERN PROTECTION (v3.6.7)\n"
|
||||
"\n"
|
||||
"real block body\n"
|
||||
).encode("utf-8")
|
||||
block = _extract_block_bytes(text)
|
||||
assert block is not None
|
||||
# The extracted block must START with the heading line, not the prose
|
||||
# mention. The prose mention had no `## ` prefix so the bytes would
|
||||
# differ obviously.
|
||||
assert block.startswith(b"## PATTERN PROTECTION (v3.6.7)\n"), (
|
||||
f"Extractor anchored on prose mention instead of heading; got: {block!r}"
|
||||
)
|
||||
assert b"real block body" in block
|
||||
assert b"This section relates to" not in block, (
|
||||
"Extractor swallowed prose; round-9 anchor regex broken"
|
||||
)
|
||||
|
||||
|
||||
def test_extractor_strips_only_file_level_bom_not_block_level() -> None:
|
||||
"""Round-8 codex P2 closure: spec § Step 0 says "the FILE's BOM (if any)
|
||||
is excluded". The exclusion is FILE-level (byte 0). A BOM inserted later
|
||||
in the file (e.g. immediately before `## PATTERN PROTECTION`) is a real
|
||||
content mutation and MUST stay in the hashed range so the gate detects it.
|
||||
"""
|
||||
from scripts.check_v3_6_8_pattern_protection import _extract_block_bytes
|
||||
BOM = b"\xef\xbb\xbf"
|
||||
base = "prelude\n\n## PATTERN PROTECTION (v3.6.7)\n\nbody\n".encode("utf-8")
|
||||
# Variant A: BOM at file start. This is a file-level BOM; spec says strip.
|
||||
file_bom_in = BOM + base
|
||||
# Variant B: BOM right before the heading (mid-file). NOT spec-stripped.
|
||||
block_bom_in = (
|
||||
"prelude\n\n".encode("utf-8")
|
||||
+ BOM
|
||||
+ "## PATTERN PROTECTION (v3.6.7)\n\nbody\n".encode("utf-8")
|
||||
)
|
||||
base_block = _extract_block_bytes(base)
|
||||
file_bom_block = _extract_block_bytes(file_bom_in)
|
||||
block_bom_block = _extract_block_bytes(block_bom_in)
|
||||
assert base_block is not None
|
||||
# File-level BOM stripped → block bytes equal to base.
|
||||
assert file_bom_block == base_block, (
|
||||
"File-level BOM (byte 0) MUST be stripped per spec § Step 0; got: "
|
||||
f"file_bom_block={file_bom_block!r} vs base_block={base_block!r}"
|
||||
)
|
||||
# Block-level BOM NOT stripped → block bytes differ from base.
|
||||
assert block_bom_block != base_block, (
|
||||
"BOM inserted before the heading (mid-file) MUST stay in the hashed "
|
||||
"range so the gate catches it (round-8 codex P2 closure). "
|
||||
f"block_bom_block={block_bom_block!r} vs base_block={base_block!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_bom_before_heading_attack_caught_by_lint() -> None:
|
||||
"""End-to-end mutation test for the round-8 BOM attack: insert U+FEFF
|
||||
immediately before the v3.6.7 heading on disk and verify the lint FAILS.
|
||||
|
||||
Round-9 anchored the marker search to a Markdown heading line. After
|
||||
that change, a BOM injected directly before `## PATTERN PROTECTION`
|
||||
breaks the heading line's `^[ \\t]*#{1,3}[ \\t]+...` shape (the BOM
|
||||
bytes sit between the line start and the `#`), so the heading regex
|
||||
no longer matches. The lint then takes the "marker missing at PR
|
||||
HEAD" diagnostic path instead of "BYTE-EQUIVALENCE FAIL". Both are
|
||||
correct — the gate rejects the mutation either way. This test
|
||||
accepts either diagnostic.
|
||||
"""
|
||||
BOM = b"\xef\xbb\xbf"
|
||||
with _Snapshot(TARGET_AGENT):
|
||||
original = TARGET_AGENT.read_bytes()
|
||||
marker = b"## PATTERN PROTECTION (v3.6.7)"
|
||||
idx = original.find(marker)
|
||||
assert idx >= 0, "fixture missing marker"
|
||||
mutated = original[:idx] + BOM + original[idx:]
|
||||
TARGET_AGENT.write_bytes(mutated)
|
||||
result = _run_lint()
|
||||
assert result.returncode == 1, (
|
||||
"BOM-before-heading mutation must be caught by the SHA gate "
|
||||
"(round-8 codex P2 closure)."
|
||||
)
|
||||
assert (
|
||||
"BYTE-EQUIVALENCE FAIL" in result.stdout
|
||||
or "marker missing at PR HEAD" in result.stdout
|
||||
), f"Expected gate rejection; got: {result.stdout}"
|
||||
|
||||
|
||||
def test_anti_self_baseline_guard_rejects_manifest_mutation_in_pr(monkeypatch) -> None:
|
||||
"""Round-2 codex P2 closure: refuse to run on PRs that mutate the v3.6.7
|
||||
manifest, because `git log -1 -- manifest` would otherwise resolve to the
|
||||
PR's own commit and the SHA comparison would hash modified content against
|
||||
itself.
|
||||
|
||||
The guard's BYTE-comparison backstop catches a worktree-level mutation
|
||||
(no commit needed). The round-4 history-scan layer catches the more
|
||||
subtle touch-and-revert pattern; that layer is exercised by the
|
||||
`test_anti_self_baseline_guard_history_scan_called` test below.
|
||||
|
||||
GITHUB_BASE_REF is set explicitly so the guard exits the "advisory mode"
|
||||
branch (no PR base detectable → guard returns advisory pass). On
|
||||
GitHub `push` event runs, GITHUB_BASE_REF is unset and origin/HEAD
|
||||
resolution may fail; this test injects the env var so the guard's
|
||||
real reject path is exercised regardless of trigger event.
|
||||
"""
|
||||
monkeypatch.setenv("GITHUB_BASE_REF", "main")
|
||||
with _Snapshot(V3_6_7_MANIFEST):
|
||||
text = V3_6_7_MANIFEST.read_text(encoding="utf-8")
|
||||
# Mutate `rationale_doc` so the byte-equivalence check fires while
|
||||
# leaving the schema valid (so the broken-schema branch isn't what
|
||||
# triggers the failure).
|
||||
mutated = text.replace(
|
||||
'"rationale_doc"',
|
||||
'"rationale_doc_mutated_for_test"',
|
||||
1,
|
||||
)
|
||||
assert mutated != text, "mutation fixture failed to apply"
|
||||
V3_6_7_MANIFEST.write_text(mutated, encoding="utf-8")
|
||||
|
||||
result = _run_lint()
|
||||
assert result.returncode == 1, (
|
||||
"Guard MUST refuse to run when v3.6.7 manifest is modified in the "
|
||||
"PR (otherwise the SHA gate would self-baseline)."
|
||||
)
|
||||
assert "anti-self-baseline guard" in result.stdout
|
||||
# Worktree-mutation triggers the byte-mismatch backstop branch.
|
||||
assert (
|
||||
"manifest bytes differ" in result.stdout
|
||||
or "manifest touched by" in result.stdout
|
||||
), f"Expected guard rejection; got: {result.stdout}"
|
||||
|
||||
|
||||
def test_anti_self_baseline_guard_history_scan_called(monkeypatch) -> None:
|
||||
"""Round-4 codex P2 closure: the guard MUST scan merge-base..HEAD for any
|
||||
commit touching the manifest, not just compare final bytes.
|
||||
|
||||
The touch-and-revert attack: commit A modifies manifest + protected block,
|
||||
commit B reverts manifest only. Final bytes match base, but `git log -1`
|
||||
still resolves to commit B and `git show B:<protected>` returns modified
|
||||
content.
|
||||
|
||||
Reproducing the attack in a unit test would require building a fake git
|
||||
history; instead, this test patches `_run_git` to inject a synthetic
|
||||
`git log merge-base..HEAD -- manifest` result and asserts the guard
|
||||
rejects when commits ARE listed (touch-and-revert simulation).
|
||||
|
||||
GITHUB_BASE_REF is set so the guard's "no PR base detectable → advisory
|
||||
pass" branch is bypassed (matters on `push` event CI where the env var
|
||||
is normally absent).
|
||||
"""
|
||||
monkeypatch.setenv("GITHUB_BASE_REF", "main")
|
||||
from scripts import check_v3_6_8_pattern_protection as mod
|
||||
|
||||
real_run_git = mod._run_git
|
||||
fake_log_output = "abcdef1234567890" * 1 # one fake touching commit SHA
|
||||
|
||||
def patched_run_git(args, cwd=None):
|
||||
# Intercept the merge-base..HEAD log query with the manifest path.
|
||||
if (
|
||||
len(args) >= 2
|
||||
and args[0] == "log"
|
||||
and any("v3_6_7_inversion_manifest.json" in a for a in args)
|
||||
and args[1] == "--format=%H"
|
||||
):
|
||||
return 0, fake_log_output, ""
|
||||
return real_run_git(args, cwd=cwd) if cwd is not None else real_run_git(args)
|
||||
|
||||
monkeypatch.setattr(mod, "_run_git", patched_run_git)
|
||||
# Call the guard directly (not via subprocess — monkeypatch wouldn't apply).
|
||||
ok, err = mod._v3_6_7_manifest_unchanged_in_pr()
|
||||
assert ok is False, "Guard must reject when history scan finds touching commits"
|
||||
assert err and "manifest touched by" in err
|
||||
assert "round-2 + round-4 codex P2 closure" in err
|
||||
|
||||
|
||||
def test_v3_6_7_marker_removed_at_head_fails() -> None:
|
||||
"""Removing the v3.6.7 marker line is a boundary violation; must hard-fail.
|
||||
|
||||
This catches an attempt to evade the SHA gate by renaming the heading
|
||||
(which would make _extract_block return None at HEAD).
|
||||
"""
|
||||
with _Snapshot(TARGET_AGENT):
|
||||
text = TARGET_AGENT.read_text(encoding="utf-8")
|
||||
# Replace marker text so the case-insensitive find returns -1.
|
||||
mutated = text.replace(PROTECTION_MARKER, "## (former pattern protection heading)")
|
||||
assert mutated != text, "test fixture failed to apply mutation"
|
||||
TARGET_AGENT.write_text(mutated, encoding="utf-8")
|
||||
result = _run_lint()
|
||||
assert result.returncode == 1
|
||||
assert "marker missing at PR HEAD" in result.stdout
|
||||
|
||||
|
||||
# ---------- Module-level smoke test for the SHA-normalization helpers ----------
|
||||
|
||||
|
||||
def test_strip_file_bom_only_at_byte_zero() -> None:
|
||||
"""File-level BOM stripping per spec § Step 0. Round-8 closure renamed
|
||||
`_normalize_bytes` → `_strip_file_bom` to make the file-level scope
|
||||
explicit (the old name was ambiguous about what it normalized).
|
||||
"""
|
||||
from scripts.check_v3_6_8_pattern_protection import _strip_file_bom
|
||||
assert _strip_file_bom(b"\xef\xbb\xbfhello") == b"hello"
|
||||
assert _strip_file_bom(b"hello") == b"hello"
|
||||
# Multi-byte payloads with no BOM are passed through unchanged.
|
||||
assert _strip_file_bom(b"\xe4\xb8\xad\xe6\x96\x87") == b"\xe4\xb8\xad\xe6\x96\x87"
|
||||
# BOM appearing in the middle of input is NOT stripped — only byte 0.
|
||||
assert _strip_file_bom(b"hi\xef\xbb\xbfworld") == b"hi\xef\xbb\xbfworld"
|
||||
|
||||
|
||||
def test_sha256_helper_matches_hashlib() -> None:
|
||||
import hashlib
|
||||
from scripts.check_v3_6_8_pattern_protection import _sha256
|
||||
assert _sha256(b"abc") == hashlib.sha256(b"abc").hexdigest()
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"scope": "v3.6.8-only",
|
||||
"rationale_doc": "docs/design/2026-04-30-ars-v3.6.8-trust-provenance-and-drift-transparency-spec.md#step-0--lint-manifest-separation-round-1-codex-f-004-amend",
|
||||
"_note_pr1": "Initial scope is empty. Step 3a (Two-Layer Citation Emission prompt block invariants on synthesis_agent / draft_writer_agent / report_compiler_agent) will populate this list. The manifest file ships in PR-1 alongside the v3.6.8 lint so the byte-equivalence SHA gate is wired and exercised against the v3.6.7 frozen manifest immediately; v3.6.8-tagged invariants land when their blocks land.",
|
||||
"files": []
|
||||
}
|
||||
@@ -83,6 +83,39 @@
|
||||
"user_notes": {
|
||||
"type": "string",
|
||||
"description": "PRIVATE FIELD. User's own annotations from their KB. May contain copyrighted excerpts. Same sharing caveat as abstract."
|
||||
},
|
||||
|
||||
"source_acquired": {
|
||||
"type": "boolean",
|
||||
"description": "v3.7.1 trust-chain field (spec § 3.1, D1). True when the original source artifact (PDF / HTML / dataset) is actually retrieved into the user's KB or workspace. Distinguishes 'we have the file' from 'we cite the bibliography record'. Adapter-set or AI-set; user-orthogonal (the user-set human-read signal is recorded in the §3.6 peer file, NOT here)."
|
||||
},
|
||||
"source_acquisition_date": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "v3.7.1 trust-chain field. ISO 8601 timestamp when the original source was acquired. Only meaningful when source_acquired=true."
|
||||
},
|
||||
"source_acquisition_path": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "v3.7.1 trust-chain field. Repo-relative or absolute path to the acquired source file. Only meaningful when source_acquired=true."
|
||||
},
|
||||
"source_verified_against_original": {
|
||||
"type": "boolean",
|
||||
"description": "v3.7.1 trust-chain field. True when an AI agent has cross-checked the bibliography entry's authors / year / title / venue against the actual content of the acquired source (NOT just a derivative bibliography). Per spec §3.1 firm rule #1, this MUST NOT be true unless source_acquired=true AND source_verification_method names a real method (not 'none')."
|
||||
},
|
||||
"source_verification_method": {
|
||||
"type": "string",
|
||||
"enum": ["codex_audit", "manual_grep", "vision_check", "none"],
|
||||
"description": "v3.7.1 trust-chain field. Method used to verify the entry against the original source. 'none' means no verification was performed; entries with 'none' MUST NOT set source_verified_against_original=true (spec §3.1 firm rule #1, round-2 R2-007 amend: 'none' is a valid enum but is forbidden in conjunction with verified=true)."
|
||||
},
|
||||
"description_source": {
|
||||
"type": "string",
|
||||
"pattern": "^(original_pdf|bibliography_v[0-9]+|secondary_summary)$",
|
||||
"description": "v3.7.1 trust-chain field. Origin of the descriptive metadata (title / authors / abstract). 'original_pdf' = read directly off the acquired source; 'bibliography_v<n>' (any non-negative integer n) = lifted from a derivative bibliography revision; 'secondary_summary' = paraphrased from another work. Surfaces description provenance independently of source acquisition. Spec § 3.1 yaml uses the literal `bibliography_v<n>` template, so the pattern accepts any integer n rather than hard-coding a finite revision count."
|
||||
},
|
||||
"description_last_audit": {
|
||||
"type": ["string", "null"],
|
||||
"description": "v3.7.1 trust-chain field. Round identifier of the most recent codex / cross-model audit that examined this entry's description, or null / the literal string 'none' when no such audit has run. The field-level type permits both null and 'none' broadly, but per spec §3.1 firm rule #2 the rule-#2 then-branch in `allOf` tightens this to the LITERAL STRING 'none' only when source_acquired=false (no original means audit cannot be substantive — round-6 codex P2 closure). null remains valid only when source_acquired=true and the entry is simply unaudited."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -96,6 +129,38 @@
|
||||
"then": {
|
||||
"required": ["adapter_name"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "v3.7.1 spec §3.1 firm rule #1 (round-2 R2-007 amend): source_verified_against_original=true REQUIRES source_acquired=true AND source_verification_method ∈ {codex_audit, manual_grep, vision_check}. The 'none' method is enumerated for shape uniformity but is FORBIDDEN in conjunction with verified=true.",
|
||||
"if": {
|
||||
"properties": { "source_verified_against_original": { "const": true } },
|
||||
"required": ["source_verified_against_original"]
|
||||
},
|
||||
"then": {
|
||||
"required": ["source_acquired", "source_verification_method"],
|
||||
"properties": {
|
||||
"source_acquired": { "const": true },
|
||||
"source_verification_method": {
|
||||
"enum": ["codex_audit", "manual_grep", "vision_check"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "v3.7.1 spec §3.1 firm rule #2 (round-6 codex P2 closure): source_acquired=false REQUIRES `description_last_audit: \"none\"` — the literal sentinel string, NOT null. Spec line 120 reads 'REQUIRES description_last_audit: none' (literal 'none'); spec line 111 yaml shows the value vocabulary as `<round_id> | none` with no null alternative. Round-1 closure made the field strictly required (presence enforced); round-6 closure removes the null alternative the field-level type permitted, since spec firm rule #2 mandates the literal sentinel. The top-level `type: [string, null]` on the field stays — null is still legal when source_acquired=true and the entry simply hasn't been audited yet — but the rule-#2 then-branch tightens to the literal string only.",
|
||||
"if": {
|
||||
"properties": { "source_acquired": { "const": false } },
|
||||
"required": ["source_acquired"]
|
||||
},
|
||||
"then": {
|
||||
"required": ["description_last_audit"],
|
||||
"properties": {
|
||||
"description_last_audit": {
|
||||
"type": "string",
|
||||
"const": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
Reference in New Issue
Block a user