oc_chat
Loading...
Searching...
No Matches
valid_audit.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tools/valid_audit.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/test_valid_audit.py"
6"""
7V-A-L-I-D — Verified Against Live Implementation Details.
8
9Purpose
10-------
11The committed, CI-gated claim-vs-code audit for the registry spec
12([OP-6], REGISTRY_SOURCE_AND_STORAGE_SPEC_V0_24.md §0.3 as amended by
13[OP-8] + erratum). It keeps every present-tense, code-facing current-state
14claim in the governed docs honest by construction: each tagged claim binds
15to a committed receipt whose per-site code fingerprints must re-locate in
16the live tree and whose claim text must still be verbatim-present at its tag.
17
18Responsibilities
19----------------
20- Parse the committed receipts JSON (the system-of-record) through a
21 pydantic-strict schema and check it agrees with the rendered markdown view.
22- Enforce the tag<->receipt bijection (both-ways orphan check).
23- Re-locate every `resolved_code_sites` fingerprint (hint range, then a
24 sliding same-line-count window): 0 = drift (fail), 1 = mechanical
25 relocation (reported, non-fatal in check mode), >=2 = ambiguous (fail).
26- Assert each receipt's `claim_text` is verbatim-present at its tag; report a
27 stale `spec_line` as a non-fatal mechanical provenance update.
28- Scan the governed docs for untagged current-state trigger lines
29 (fence/comment-aware) against `tools/valid_audit_lexicon.txt`, honouring
30 token-scoped inline `@claim-exempt` waivers.
31- Fail closed on any `DISPUTED` receipt unless `--allow-disputed` (a
32 pre-flip-only relaxation that touches ONLY the `DISPUTED` gate).
33- refresh mode rewrites mechanical relocations / stale `spec_line`s (and
34 regenerates the markdown view) after a human re-verifies real drift.
35
36Diagnostics
37-----------
38Domain: OC_CHAT.MAIN
39Levels:
40 L2 - lifecycle events
41 L3 - semantic details
42 L4 - deep tracing
43
44Contracts
45---------
46- check mode is read-only and MUST NOT mutate the receipts file.
47- Exit 0 clean (a reported pending relocation / stale spec_line is NOT a
48 violation), 1 audit violations, 2 usage/config error.
49- No `oc_chat` imports (never import the package under development from a
50 tools/-launched script); stdlib + pydantic + oc_diagnostics only.
51
52Dependencies
53------------
54- oc_diagnostics (structured debug logging via _dbg)
55- pydantic (strict receipt schema at the audit boundary)
56- argparse (the --check, --refresh and --refresh-relocations modes and their options)
57- hashlib (SHA-256 code and claim fingerprints)
58- json (reads the receipts file and writes its canonical JSON and the --json report)
59- re (the claim tag and waiver patterns and the lexicon trigger patterns)
60- pathlib (Path - the audit root and the governed doc, receipt and code site paths)
61"""
62from __future__ import annotations
63
64import argparse
65import hashlib
66import json
67import re
68import sys
69from pathlib import Path
70from typing import Literal
71
72from oc_diagnostics import _dbg
73from pydantic import BaseModel, ConfigDict, Field, ValidationError
74
75_dbg("OC_CHAT.MAIN", "module", "valid_audit loaded", 4)
76
77# --------------------------------------------------------------------------
78# Configuration — paths are repo-root-relative so a fixture tree under
79# `--root` is audited identically to the live tree.
80# --------------------------------------------------------------------------
81GOVERNED_DOCS = (
82 # OCT-LINT: disable=no_hardcoded_secrets reason="Doc paths, HTML-comment regexes, and markdown-render/help strings are not secrets"
83 "oc_chat/docs/architecture/orchestration/REGISTRY_SOURCE_AND_STORAGE_SPEC_V0_24.md",
84 # OCT-LINT: disable=no_hardcoded_secrets reason="Doc paths, HTML-comment regexes, and markdown-render/help strings are not secrets"
85 "oc_chat/docs/architecture/orchestration/REGISTRY_DATA_MODEL.md",
86)
87RECEIPTS_JSON = "oc_chat/docs/architecture/orchestration/audits/registry_v0_24_valid_claims.json"
88RECEIPTS_MD = "oc_chat/docs/architecture/orchestration/audits/registry_v0_24_valid_claims.md"
89LEXICON_FILE = "tools/valid_audit_lexicon.txt"
90
91# Fingerprint preimage schema/version tag (a `hash_preimage`-style prefix so a
92# preimage-structure change is detectable). Bump only if the preimage changes.
93FP_SCHEMA = "vald1"
94
95RECEIPTS_SCHEMA_VERSION = 1
96
97# Inline markers (HTML comments) in the governed docs.
98_TAG_RE = re.compile(r"<!--\s*@claim:\s*(VC-\d{4})\s*-->")
99# A waiver may name one or more comma-separated trigger tokens before the
100# ` — <reason>` separator, e.g. `<!-- @claim-exempt: landed,code-verified — meta -->`.
101# OCT-LINT: disable=no_hardcoded_secrets reason="Doc paths, HTML-comment regexes, and markdown-render/help strings are not secrets"
102_EXEMPT_RE = re.compile(r"<!--\s*@claim-exempt:\s*([A-Za-z0-9_,-]+?)\s*(?:—|--)\s*.+?-->")
103
104
105# --------------------------------------------------------------------------
106# Receipt schema (pydantic-strict; the audit boundary, per spec §3.2 ethos)
107# --------------------------------------------------------------------------
108class CodeSite(BaseModel):
109 """One resolved code location backing a claim, with its own fingerprint."""
110
111 model_config = ConfigDict(extra="forbid", strict=True)
112
113 path: str
114 start: int = Field(ge=1)
115 end: int = Field(ge=1)
116 symbol: str | None = None
117 role: Literal["primary", "secondary", "test", "ddl"] = "primary"
118 code_fingerprint: str
119
120
121class Receipt(BaseModel):
122 """A single claim receipt — the full amended spec §0.3 field set."""
123
124 model_config = ConfigDict(extra="forbid", strict=True)
125
126 claim_id: str = Field(pattern=r"^VC-\d{4}$")
127 spec_file: str
128 spec_line: int = Field(ge=1)
129 claim_text: str = Field(min_length=1)
130 claim_kind: Literal[
131 "behavior", "schema", "cli", "error-code", "test-surface", "ci-surface"
132 ]
133 expected_status: Literal["VERIFIED_CURRENT", "TARGET", "DORMANT", "DISPUTED"]
134 resolved_code_sites: list[CodeSite] = Field(min_length=1)
135 claim_fingerprint: str
136 evidence_command_or_test: str
137 last_verified_commit: str
138 owner: str
139 reviewer: str | None = None
140 updated_at: str
141
142
143class ReceiptFile(BaseModel):
144 """The committed receipts artifact (canonical JSON system-of-record)."""
145
146 model_config = ConfigDict(extra="forbid", strict=True)
147
148 schema_version: int
149 claims: list[Receipt]
150
151
152# --------------------------------------------------------------------------
153# Fingerprinting
154# --------------------------------------------------------------------------
155def code_fingerprint(lines: list[str], start: int, end: int) -> str:
156 """SHA-256 over the newline-normalised bytes of lines[start:end] (1-indexed, inclusive)."""
157
158 segment = "\n".join(lines[start - 1 : end])
159 digest = hashlib.sha256(segment.encode("utf-8")).hexdigest()
160 return f"{FP_SCHEMA}:code:{digest}"
161
162
163def claim_fingerprint(claim_text: str) -> str:
164 """SHA-256 over the receipt's stored `claim_text` (newline-normalised)."""
165
166 normalized = claim_text.replace("\r\n", "\n").replace("\r", "\n")
167 digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
168 return f"{FP_SCHEMA}:claim:{digest}"
169
170
171def relocate_site(lines: list[str], site: CodeSite) -> tuple[str, list[tuple[int, int]]]:
172 """Return (status, candidates) for a site fingerprint.
173
174 status ∈ {"located", "relocated", "drift", "ambiguous"}. "located" means
175 the hinted range still carries the fingerprint (the hint disambiguates even
176 if the same bytes recur elsewhere). Otherwise a same-line-count window slides
177 over the file: 0 = drift, exactly 1 = relocated, >=2 = ambiguous.
178 """
179
180 span = site.end - site.start + 1
181 if 1 <= site.start <= len(lines) and site.end <= len(lines):
182 if code_fingerprint(lines, site.start, site.end) == site.code_fingerprint:
183 return ("located", [(site.start, site.end)])
184 candidates: list[tuple[int, int]] = []
185 for start in range(1, len(lines) - span + 2):
186 end = start + span - 1
187 if code_fingerprint(lines, start, end) == site.code_fingerprint:
188 candidates.append((start, end))
189 if not candidates:
190 return ("drift", [])
191 if len(candidates) == 1:
192 return ("relocated", candidates)
193 return ("ambiguous", candidates)
194
195
196# --------------------------------------------------------------------------
197# Governed-doc scanning (tags, waivers, fence/comment-aware trigger lexicon)
198# --------------------------------------------------------------------------
199class LexiconEntry(BaseModel):
200 """One curated trigger token + its compiled-elsewhere pattern string."""
201
202 model_config = ConfigDict(extra="forbid", strict=True)
203
204 token: str
205 pattern: str
206
207
208def load_lexicon(root: Path) -> list[LexiconEntry]:
209 """Parse `tools/valid_audit_lexicon.txt` (`token :: regex`; `#` comments)."""
210
211 path = root / LEXICON_FILE
212 entries: list[LexiconEntry] = []
213 for raw in path.read_text(encoding="utf-8").splitlines():
214 line = raw.strip()
215 if not line or line.startswith("#") or "::" not in line:
216 continue
217 token, pattern = line.split("::", 1)
218 entries.append(LexiconEntry(token=token.strip(), pattern=pattern.strip()))
219 return entries
220
221
222def _strip_code_and_comments(line: str, in_html_comment: bool) -> tuple[str, bool]:
223 """Remove inline-code spans and HTML-comment interiors (comment state carries across lines)."""
224
225 out: list[str] = []
226 i = 0
227 n = len(line)
228 while i < n:
229 if in_html_comment:
230 idx = line.find("-->", i)
231 if idx == -1:
232 return ("".join(out), True)
233 i = idx + 3
234 in_html_comment = False
235 continue
236 if line.startswith("<!--", i):
237 idx = line.find("-->", i + 4)
238 if idx == -1:
239 return ("".join(out), True)
240 i = idx + 3
241 continue
242 ch = line[i]
243 if ch == "`":
244 idx = line.find("`", i + 1)
245 if idx == -1:
246 out.append(ch)
247 i += 1
248 continue
249 i = idx + 1
250 continue
251 out.append(ch)
252 i += 1
253 return ("".join(out), in_html_comment)
254
255
256def find_tags(lines: list[str]) -> dict[str, int]:
257 """Map each `@claim: <ID>` to its 1-indexed line (last wins on a dup — flagged elsewhere)."""
258
259 tags: dict[str, int] = {}
260 for idx, line in enumerate(lines, start=1):
261 for match in _TAG_RE.finditer(line):
262 tags.setdefault(match.group(1), idx)
263 return tags
264
265
266def find_all_tag_ids(lines: list[str]) -> list[str]:
267 """Every `@claim: <ID>` occurrence in order (to detect duplicate tags)."""
268
269 ids: list[str] = []
270 for line in lines:
271 for match in _TAG_RE.finditer(line):
272 ids.append(match.group(1))
273 return ids
274
275
276def _line_is_tagged(lines: list[str], idx: int) -> bool:
277 """A trigger line is tagged if it (or the immediately-preceding line) carries a @claim tag."""
278
279 if _TAG_RE.search(lines[idx - 1]):
280 return True
281 return idx >= 2 and bool(_TAG_RE.search(lines[idx - 2]))
282
283
284def _waived_tokens(lines: list[str], idx: int) -> set[str]:
285 """Trigger tokens waived on the trigger line or the immediately-preceding line."""
286
287 tokens: set[str] = set()
288 for line_idx in (idx - 1, idx - 2):
289 if line_idx < 0:
290 continue
291 for match in _EXEMPT_RE.finditer(lines[line_idx]):
292 tokens.update(tok.strip() for tok in match.group(1).split(",") if tok.strip())
293 return tokens
294
295
297 lines: list[str], lexicon: list[LexiconEntry], rel: str
298) -> list[dict[str, object]]:
299 """Fence/comment-aware scan for current-state trigger lines lacking a tag or waiver."""
300
301 compiled = [(entry, re.compile(entry.pattern)) for entry in lexicon]
302 violations: list[dict[str, object]] = []
303 in_fence = False
304 in_html_comment = False
305 for idx, raw in enumerate(lines, start=1):
306 stripped = raw.strip()
307 if stripped.startswith("```") or stripped.startswith("~~~"):
308 in_fence = not in_fence
309 continue
310 if in_fence:
311 continue
312 scannable, in_html_comment = _strip_code_and_comments(raw, in_html_comment)
313 for entry, pattern in compiled:
314 if not pattern.search(scannable):
315 continue
316 if _line_is_tagged(lines, idx):
317 continue
318 if entry.token in _waived_tokens(lines, idx):
319 continue
320 violations.append(
321 {
322 "kind": "untagged_trigger",
323 "file": rel,
324 "line": idx,
325 "token": entry.token,
326 "text": raw.strip(),
327 }
328 )
329 return violations
330
331
332# --------------------------------------------------------------------------
333# Markdown view rendering (deterministic; refresh regenerates, check compares)
334# --------------------------------------------------------------------------
335def render_markdown(receipt_file: ReceiptFile) -> str:
336 """Render the committed markdown view from the JSON system-of-record (deterministic)."""
337
338 out: list[str] = []
339 out.append("# V-A-L-I-D receipts — registry V0.24 claim-vs-code audit")
340 out.append("")
341 out.append(
342 # OCT-LINT: disable=no_hardcoded_secrets reason="Doc paths, HTML-comment regexes, and markdown-render/help strings are not secrets"
343 "GENERATED from `registry_v0_24_valid_claims.json` by "
344 "`python tools/valid_audit.py --refresh-relocations` / `--refresh <id>`; "
345 "do not hand-edit. The JSON is the system-of-record."
346 )
347 out.append("")
348 out.append(f"- schema_version: {receipt_file.schema_version}")
349 out.append(f"- claim count: {len(receipt_file.claims)}")
350 out.append("")
351 out.append(
352 # OCT-LINT: disable=no_hardcoded_secrets reason="Doc paths, HTML-comment regexes, and markdown-render/help strings are not secrets"
353 "V-A-L-I-D verifies structure + code-site existence + fingerprint drift "
354 "(with mechanical relocation), NOT prose semantics — the claim<->code "
355 "correspondence is human-verified when a receipt is authored and re-checked "
356 "on real drift (spec §0.3 / Design boundaries)."
357 )
358 out.append("")
359 for receipt in sorted(receipt_file.claims, key=lambda r: r.claim_id):
360 out.append(f"## {receipt.claim_id} — {receipt.expected_status}")
361 out.append("")
362 out.append(f"- **claim_kind:** {receipt.claim_kind}")
363 out.append(f"- **spec:** `{receipt.spec_file}`:{receipt.spec_line}")
364 out.append(f"- **claim_text:** {receipt.claim_text}")
365 out.append(f"- **claim_fingerprint:** `{receipt.claim_fingerprint}`")
366 out.append("- **resolved_code_sites:**")
367 for site in receipt.resolved_code_sites:
368 symbol = f" `{site.symbol}`" if site.symbol else ""
369 out.append(
370 f" - [{site.role}]{symbol} `{site.path}`:{site.start}-{site.end} "
371 f"— `{site.code_fingerprint}`"
372 )
373 out.append(f"- **evidence:** {receipt.evidence_command_or_test}")
374 out.append(f"- **last_verified_commit:** `{receipt.last_verified_commit}`")
375 reviewer = receipt.reviewer if receipt.reviewer is not None else "(none — T1)"
376 out.append(f"- **owner / reviewer:** {receipt.owner} / {reviewer}")
377 out.append(f"- **updated_at:** {receipt.updated_at}")
378 out.append("")
379 return "\n".join(out).rstrip("\n") + "\n"
380
381
382def canonical_json(receipt_file: ReceiptFile) -> str:
383 """Canonical JSON (sorted keys, LF, 2-space indent, trailing newline)."""
384
385 payload = receipt_file.model_dump(mode="json", exclude_none=False)
386 return json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
387
388
389# --------------------------------------------------------------------------
390# Loading
391# --------------------------------------------------------------------------
392class ConfigError(Exception):
393 """Usage / configuration error → exit 2."""
394
395
396def load_receipts(root: Path) -> ReceiptFile:
397 """Load + strict-validate the receipts JSON (raises ConfigError on absence/parse/schema)."""
398
399 path = root / RECEIPTS_JSON
400 if not path.is_file():
401 raise ConfigError(f"receipts JSON not found: {RECEIPTS_JSON}")
402 try:
403 raw = json.loads(path.read_text(encoding="utf-8"))
404 except json.JSONDecodeError as exc:
405 raise ConfigError(f"receipts JSON is not valid JSON: {exc}") from exc
406 try:
407 return ReceiptFile.model_validate(raw)
408 except ValidationError as exc:
409 raise ConfigError(f"receipts JSON fails the strict schema:\n{exc}") from exc
410
411
412def _read_lines(root: Path, rel: str) -> list[str]:
413 path = root / rel
414 if not path.is_file():
415 raise ConfigError(f"governed doc not found: {rel}")
416 return path.read_text(encoding="utf-8").splitlines()
417
418
419# --------------------------------------------------------------------------
420# The audit
421# --------------------------------------------------------------------------
422class AuditResult(BaseModel):
423 """Structured audit outcome (also the `--json` payload shape)."""
424
425 model_config = ConfigDict(extra="forbid")
426
427 ok: bool
428 violations: list[dict[str, object]]
429 pending_relocations: list[dict[str, object]]
430 stale_spec_lines: list[dict[str, object]]
431 disputed: list[dict[str, object]]
432 allow_disputed: bool
433
434
435def run_audit(root: Path, allow_disputed: bool) -> AuditResult:
436 """Run the full read-only audit and return a structured result."""
437
438 violations: list[dict[str, object]] = []
439 pending: list[dict[str, object]] = []
440 stale_lines: list[dict[str, object]] = []
441 disputed: list[dict[str, object]] = []
442
443 receipt_file = load_receipts(root)
444 receipts = {r.claim_id: r for r in receipt_file.claims}
445
446 # Schema-level: duplicate ids in the JSON itself.
447 seen: set[str] = set()
448 for receipt in receipt_file.claims:
449 if receipt.claim_id in seen:
450 violations.append({"kind": "duplicate_receipt_id", "claim_id": receipt.claim_id})
451 seen.add(receipt.claim_id)
452
453 # JSON <-> markdown agreement.
454 md_path = root / RECEIPTS_MD
455 if not md_path.is_file():
456 raise ConfigError(f"receipts markdown view not found: {RECEIPTS_MD}")
457 if md_path.read_text(encoding="utf-8") != render_markdown(receipt_file):
458 violations.append({"kind": "json_md_disagreement", "detail": RECEIPTS_MD})
459
460 # claim_fingerprint self-consistency.
461 for receipt in receipt_file.claims:
462 if receipt.claim_fingerprint != claim_fingerprint(receipt.claim_text):
463 violations.append(
464 {"kind": "claim_fingerprint_mismatch", "claim_id": receipt.claim_id}
465 )
466
467 # Tag <-> receipt bijection + claim-text presence + spec_line provenance.
468 doc_lines: dict[str, list[str]] = {rel: _read_lines(root, rel) for rel in GOVERNED_DOCS}
469 all_tag_ids: list[str] = []
470 tag_line: dict[tuple[str, str], int] = {}
471 for rel, lines in doc_lines.items():
472 ids_in_doc = find_all_tag_ids(lines)
473 all_tag_ids.extend(ids_in_doc)
474 for cid, ln in find_tags(lines).items():
475 tag_line[(rel, cid)] = ln
476
477 tag_id_set = set(all_tag_ids)
478 if len(all_tag_ids) != len(tag_id_set):
479 dupes = sorted({cid for cid in all_tag_ids if all_tag_ids.count(cid) > 1})
480 violations.append({"kind": "duplicate_tag", "claim_ids": dupes})
481
482 for cid in tag_id_set:
483 if cid not in receipts:
484 violations.append({"kind": "tag_without_receipt", "claim_id": cid})
485 for cid in receipts:
486 if cid not in tag_id_set:
487 violations.append({"kind": "receipt_without_tag", "claim_id": cid})
488
489 # Per-receipt checks.
490 for receipt in sorted(receipt_file.claims, key=lambda r: r.claim_id):
491 cid = receipt.claim_id
492 if receipt.expected_status == "DISPUTED":
493 disputed.append({"claim_id": cid, "claim_text": receipt.claim_text})
494 if not allow_disputed:
495 violations.append({"kind": "disputed_receipt", "claim_id": cid})
496
497 # claim-text presence + spec_line provenance (against its tag).
498 key = (receipt.spec_file, cid)
499 if key in tag_line:
500 true_line = tag_line[key]
501 lines = doc_lines[receipt.spec_file]
502 on_line = receipt.claim_text in lines[true_line - 1]
503 on_prev = true_line >= 2 and receipt.claim_text in lines[true_line - 2]
504 if not (on_line or on_prev):
505 violations.append({"kind": "claim_text_drift", "claim_id": cid})
506 if receipt.spec_line != true_line:
507 stale_lines.append(
508 {"claim_id": cid, "stored": receipt.spec_line, "actual": true_line}
509 )
510 # (a receipt whose spec_file isn't governed, or whose tag is missing, is
511 # already caught by the bijection check above.)
512
513 # Code-site relocation.
514 for site in receipt.resolved_code_sites:
515 code_path = root / site.path
516 if not code_path.is_file():
517 violations.append(
518 {"kind": "code_site_missing_file", "claim_id": cid, "path": site.path}
519 )
520 continue
521 code_lines = code_path.read_text(encoding="utf-8").splitlines()
522 status, candidates = relocate_site(code_lines, site)
523 if status == "drift":
524 violations.append(
525 {"kind": "code_drift", "claim_id": cid, "path": site.path,
526 "hint": [site.start, site.end]}
527 )
528 elif status == "ambiguous":
529 violations.append(
530 {"kind": "ambiguous_relocation", "claim_id": cid, "path": site.path,
531 "candidates": [list(c) for c in candidates]}
532 )
533 elif status == "relocated":
534 new_start, new_end = candidates[0]
535 pending.append(
536 {"claim_id": cid, "path": site.path,
537 "stored": [site.start, site.end], "actual": [new_start, new_end]}
538 )
539
540 ok = not violations
541 return AuditResult(
542 ok=ok,
543 violations=violations,
544 pending_relocations=pending,
545 stale_spec_lines=stale_lines,
546 disputed=disputed,
547 allow_disputed=allow_disputed,
548 )
549
550
551# --------------------------------------------------------------------------
552# Refresh mode (never run in CI)
553# --------------------------------------------------------------------------
554def refresh(root: Path, only_relocations: bool, claim_id: str | None) -> int:
555 """Rewrite mechanical relocations / stale spec_lines + regenerate the markdown view."""
556
557 func = "refresh"
558 receipt_file = load_receipts(root)
559 doc_lines: dict[str, list[str]] = {rel: _read_lines(root, rel) for rel in GOVERNED_DOCS}
560 tag_line: dict[tuple[str, str], int] = {}
561 for rel, lines in doc_lines.items():
562 for cid, ln in find_tags(lines).items():
563 tag_line[(rel, cid)] = ln
564
565 changed = 0
566 for receipt in receipt_file.claims:
567 if claim_id is not None and receipt.claim_id != claim_id:
568 continue
569 key = (receipt.spec_file, receipt.claim_id)
570 if key in tag_line and receipt.spec_line != tag_line[key]:
571 receipt.spec_line = tag_line[key]
572 changed += 1
573 for site in receipt.resolved_code_sites:
574 code_path = root / site.path
575 if not code_path.is_file():
576 continue
577 code_lines = code_path.read_text(encoding="utf-8").splitlines()
578 status, candidates = relocate_site(code_lines, site)
579 if status == "relocated":
580 site.start, site.end = candidates[0]
581 changed += 1
582
583 (root / RECEIPTS_JSON).write_text(canonical_json(receipt_file), encoding="utf-8", newline="\n")
584 (root / RECEIPTS_MD).write_text(render_markdown(receipt_file), encoding="utf-8", newline="\n")
585 _dbg("OC_CHAT.MAIN", func, f"{changed} field(s) rewritten", 2)
586 print(f"refresh: {changed} mechanical field(s) rewritten; markdown view regenerated")
587 return 0
588
589
590# --------------------------------------------------------------------------
591# Reporting + CLI
592# --------------------------------------------------------------------------
593def _format_report(result: AuditResult) -> str:
594 lines: list[str] = []
595 if result.violations:
596 lines.append(f"V-A-L-I-D: {len(result.violations)} violation(s):")
597 for v in result.violations:
598 lines.append(f" - {json.dumps(v, sort_keys=True)}")
599 else:
600 lines.append("V-A-L-I-D: clean (0 violations).")
601 if result.disputed:
602 state = "reported (relaxed by --allow-disputed)" if result.allow_disputed else "FATAL"
603 lines.append(f"DISPUTED rows [{state}]: {len(result.disputed)}")
604 for d in result.disputed:
605 lines.append(f" - {d['claim_id']}")
606 if result.pending_relocations or result.stale_spec_lines:
607 lines.append("Relocations pending (mechanical, non-fatal — run --refresh-relocations):")
608 for p in result.pending_relocations:
609 lines.append(
610 f" - {p['claim_id']} {p['path']} {p['stored']} -> {p['actual']}"
611 )
612 for s in result.stale_spec_lines:
613 lines.append(
614 f" - {s['claim_id']} spec_line {s['stored']} -> {s['actual']}"
615 )
616 return "\n".join(lines)
617
618
619def check(root: Path, allow_disputed: bool, as_json: bool) -> int:
620 """check mode — read-only; the canonical CI/ACs/docs invocation."""
621
622 lexicon = load_lexicon(root)
623 result = run_audit(root, allow_disputed)
624 for rel in GOVERNED_DOCS:
625 lines = _read_lines(root, rel)
626 result.violations.extend(scan_untagged_triggers(lines, lexicon, rel))
627 result.ok = not result.violations
628 if as_json:
629 print(json.dumps(result.model_dump(), indent=2, sort_keys=True))
630 else:
631 print(_format_report(result))
632 return 0 if result.ok else 1
633
634
635def main(argv: list[str] | None = None) -> int:
636 parser = argparse.ArgumentParser(
637 # OCT-LINT: disable=no_hardcoded_secrets reason="Doc paths, HTML-comment regexes, and markdown-render/help strings are not secrets"
638 description="V-A-L-I-D claim-vs-code audit for the registry V0.24 spec.",
639 )
640 parser.add_argument("--check", action="store_true", help="Read-only audit (CI/ACs/docs invocation).")
641 parser.add_argument(
642 "--allow-disputed",
643 action="store_true",
644 # OCT-LINT: disable=no_hardcoded_secrets reason="Doc paths, HTML-comment regexes, and markdown-render/help strings are not secrets"
645 help="Relax ONLY the DISPUTED gate (pre-flip-only; forbidden after the gate-blocking flip).",
646 )
647 parser.add_argument("--refresh", metavar="CLAIM_ID", help="Refresh one receipt (after re-verifying real drift).")
648 parser.add_argument(
649 "--refresh-relocations",
650 action="store_true",
651 help="Rewrite all mechanical relocations / stale spec_lines + regenerate the markdown view.",
652 )
653 parser.add_argument("--root", default=None, help="Repo root to audit (default: this tool's repo).")
654 parser.add_argument("--json", action="store_true", help="Emit a JSON report (check mode).")
655 args = parser.parse_args(argv)
656
657 root = Path(args.root).resolve() if args.root else Path(__file__).resolve().parent.parent
658
659 modes = [bool(args.check), bool(args.refresh), bool(args.refresh_relocations)]
660 if sum(modes) != 1:
661 print("usage: exactly one of --check / --refresh <id> / --refresh-relocations", file=sys.stderr)
662 return 2
663 try:
664 if args.check:
665 return check(root, args.allow_disputed, args.json)
666 if args.refresh_relocations:
667 return refresh(root, only_relocations=True, claim_id=None)
668 return refresh(root, only_relocations=False, claim_id=args.refresh)
669 except ConfigError as exc:
670 print(f"config error: {exc}", file=sys.stderr)
671 return 2
672
673
674if __name__ == "__main__":
675 sys.exit(main())
str code_fingerprint(list[str] lines, int start, int end)
int check(Path root, bool allow_disputed, bool as_json)
str canonical_json(ReceiptFile receipt_file)
dict[str, int] find_tags(list[str] lines)
int main(list[str]|None argv=None)
ReceiptFile load_receipts(Path root)
str claim_fingerprint(str claim_text)
list[LexiconEntry] load_lexicon(Path root)
bool _line_is_tagged(list[str] lines, int idx)
list[str] _read_lines(Path root, str rel)
str render_markdown(ReceiptFile receipt_file)
tuple[str, list[tuple[int, int]]] relocate_site(list[str] lines, CodeSite site)
str _format_report(AuditResult result)
int refresh(Path root, bool only_relocations, str|None claim_id)
tuple[str, bool] _strip_code_and_comments(str line, bool in_html_comment)
list[str] find_all_tag_ids(list[str] lines)
set[str] _waived_tokens(list[str] lines, int idx)
list[dict[str, object]] scan_untagged_triggers(list[str] lines, list[LexiconEntry] lexicon, str rel)
AuditResult run_audit(Path root, bool allow_disputed)