返回 ppt-master
prompt_audit.py
根目录 / skills / ppt-master / scripts / prompt_audit.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Prompt Budget Audit Tool
4
5 Audits the repository's agent-facing documents without modifying them. Reports
6 exact o200k_base token counts, declared load-set budgets, Markdown references,
7 registry claims, schema-definition candidates, and cross-file duplication.
8
9 Usage:
10 python3 scripts/prompt_audit.py
11 python3 scripts/prompt_audit.py --json
12 python3 scripts/prompt_audit.py --root /path/to/ppt-master
13
14 Examples:
15 python3 skills/ppt-master/scripts/prompt_audit.py
16 python3 skills/ppt-master/scripts/prompt_audit.py --json | python3 -m json.tool
17
18 Dependencies:
19 tiktoken (o200k_base encoding)
20 """
21
22 from __future__ import annotations
23
24 import argparse
25 import hashlib
26 import json
27 import re
28 import statistics
29 import sys
30 from collections import Counter, defaultdict
31 from dataclasses import asdict, dataclass, field
32 from difflib import SequenceMatcher
33 from pathlib import Path, PurePosixPath
34 from typing import Any, Iterable
35 from urllib.parse import unquote
36
37 from console_encoding import configure_utf8_stdio
38
39 configure_utf8_stdio()
40
41
42 _MARKDOWN_LINK_RE = re.compile(r"(?<!!)\[[^\]]+\]\(([^)]+)\)")
43 _WORD_RE = re.compile(r"[0-9A-Za-z_./<>:+-]+|[\u3400-\u9fff]")
44 _HEADING_RE = re.compile(r"^#{1,6}\s+")
45 _FENCE_RE = re.compile(r"^\s*(```|~~~)")
46 _SCHEMA_HEADING_RE = re.compile(r"^##\s+([A-Za-z][A-Za-z0-9_.-]*)\s*$")
47 _REGISTRY_INDEX_ENTRY_RE = re.compile(
48 r"^\|\s*\[`(?P<label>[A-Za-z0-9][A-Za-z0-9_-]*)`\]"
49 r"\(\./(?P<target>[A-Za-z0-9][A-Za-z0-9_-]*)\.md(?:#[^)]*)?\)\s*\|",
50 re.MULTILINE,
51 )
52 _AUTHORITY_TERMS_RE = re.compile(
53 r"\b(authorit(?:y|ative)|owns?|owner|source of truth|wins for)\b|权威|唯一事实源",
54 re.IGNORECASE,
55 )
56 _EXTERNAL_SCHEMES = ("http://", "https://", "mailto:", "data:", "javascript:")
57 _SEVERITY_ORDER = {"error": 0, "warning": 1}
58 _SCHEMA_PROJECTION_ROLES = frozenset(
59 {"producer", "consumer", "reference", "compatibility"}
60 )
61
62
63 class AuditError(RuntimeError):
64 """Represent a user-actionable audit setup failure."""
65
66
67 @dataclass
68 class Finding:
69 severity: str
70 code: str
71 message: str
72 path: str = ""
73 line: int = 0
74 related: list[str] = field(default_factory=list)
75
76
77 @dataclass
78 class Document:
79 path: str
80 absolute_path: Path
81 text: str
82 tokens: int
83
84
85 @dataclass
86 class Paragraph:
87 path: str
88 line: int
89 text: str
90 normalized: str
91 words: tuple[str, ...]
92
93
94 @dataclass
95 class ReferenceEdge:
96 source: str
97 line: int
98 target: str
99 authority_candidate: bool = False
100
101
102 def _relative_path(root: Path, path: Path) -> str:
103 return path.resolve().relative_to(root.resolve()).as_posix()
104
105
106 def _read_utf8(path: Path) -> str:
107 try:
108 return path.read_text(encoding="utf-8")
109 except (OSError, UnicodeError) as exc:
110 raise AuditError(f"Cannot read UTF-8 file {path}: {exc}") from exc
111
112
113 def _validate_fixed_budget(label: str, budget: Any) -> None:
114 """Require durable rounded ceilings instead of current-count ratchets."""
115 if not isinstance(budget, int) or isinstance(budget, bool) or budget < 1:
116 raise AuditError(f"{label} must be a positive integer")
117 increment = 250 if budget < 10_000 else 1_000 if budget < 100_000 else 5_000
118 if budget % increment:
119 raise AuditError(
120 f"{label} must use a fixed {increment}-token increment, got {budget}"
121 )
122
123
124 def load_manifest(path: Path) -> dict[str, Any]:
125 """Load and validate the prompt-audit manifest."""
126 try:
127 raw = json.loads(_read_utf8(path))
128 except json.JSONDecodeError as exc:
129 raise AuditError(f"Invalid JSON manifest {path}: {exc}") from exc
130
131 if not isinstance(raw, dict) or raw.get("schema_version") != 1:
132 raise AuditError("Manifest must be an object with schema_version: 1")
133 for key in (
134 "audit_only",
135 "runtime_consumed",
136 "budget_policy",
137 "encoding",
138 "documents",
139 "load_sets",
140 ):
141 if key not in raw:
142 raise AuditError(f"Manifest is missing required key: {key}")
143 if raw["audit_only"] is not True or raw["runtime_consumed"] is not False:
144 raise AuditError("Manifest must remain audit-only and excluded from runtime loading")
145 if raw["budget_policy"] != "fixed_upper_bound":
146 raise AuditError("Manifest budget_policy must remain fixed_upper_bound")
147 if raw["encoding"] != "o200k_base":
148 raise AuditError("Manifest encoding must remain o200k_base")
149 documents = raw["documents"]
150 if not isinstance(documents, dict):
151 raise AuditError("documents must be an object")
152 _validate_fixed_budget("documents.max_tokens", documents.get("max_tokens"))
153 for key in ("load_sets", "file_budgets", "duplicates", "coverage"):
154 if key in raw and not isinstance(raw[key], dict):
155 raise AuditError(f"{key} must be an object")
156 for key in ("authority_edges", "registries", "schema_grammars"):
157 if key in raw and not isinstance(raw[key], list):
158 raise AuditError(f"{key} must be an array")
159 for file_path, budget in raw.get("file_budgets", {}).items():
160 _validate_fixed_budget(f"file_budgets.{file_path}", budget)
161 for name, load_set in raw.get("load_sets", {}).items():
162 if not isinstance(load_set, dict):
163 raise AuditError(f"load_sets.{name} must be an object")
164 _validate_fixed_budget(
165 f"load_sets.{name}.max_tokens",
166 load_set.get("max_tokens"),
167 )
168
169 schema_configs = raw.get("schema_grammars", [])
170 for index, config in enumerate(schema_configs):
171 label = f"schema_grammars[{index}]"
172 if not isinstance(config, dict):
173 raise AuditError(f"{label} must be an object")
174 source = config.get("source")
175 if not isinstance(source, str) or not source.strip():
176 raise AuditError(f"{label}.source must be a non-empty path")
177 fields = config.get("fields")
178 if fields is not None and (
179 not isinstance(fields, list)
180 or not fields
181 or not all(isinstance(item, str) and item.strip() for item in fields)
182 or len(set(fields)) != len(fields)
183 ):
184 raise AuditError(
185 f"{label}.fields must be a non-empty array of unique field names"
186 )
187 scan = config.get("scan")
188 if scan is not None and (
189 not isinstance(scan, list)
190 or not scan
191 or not all(isinstance(item, str) and item.strip() for item in scan)
192 ):
193 raise AuditError(
194 f"{label}.scan must be a non-empty array of path patterns"
195 )
196 accepted = config.get("accepted", [])
197 if not isinstance(accepted, list):
198 raise AuditError(f"{label}.accepted must be an array")
199 accepted_fields: set[str] = set()
200 for accepted_index, entry in enumerate(accepted):
201 accepted_label = f"{label}.accepted[{accepted_index}]"
202 field_name = entry.get("field") if isinstance(entry, dict) else None
203 owner_fingerprint = (
204 entry.get("owner_fingerprint") if isinstance(entry, dict) else None
205 )
206 projections = entry.get("projections") if isinstance(entry, dict) else None
207 if (
208 not isinstance(entry, dict)
209 or not isinstance(field_name, str)
210 or not field_name.strip()
211 or not isinstance(owner_fingerprint, str)
212 or re.fullmatch(r"[0-9a-f]{12}", owner_fingerprint) is None
213 or not isinstance(projections, list)
214 or not projections
215 ):
216 raise AuditError(
217 f"{accepted_label} requires field, a 12-hex owner_fingerprint, "
218 "and non-empty projections"
219 )
220 if field_name in accepted_fields:
221 raise AuditError(
222 f"{label}.accepted repeats field {field_name!r}"
223 )
224 accepted_fields.add(field_name)
225 projection_paths: set[str] = set()
226 for projection_index, projection in enumerate(projections):
227 projection_label = (
228 f"{accepted_label}.projections[{projection_index}]"
229 )
230 reason = (
231 projection.get("reason")
232 if isinstance(projection, dict)
233 else None
234 )
235 projection_path = (
236 projection.get("path")
237 if isinstance(projection, dict)
238 else None
239 )
240 if (
241 not isinstance(projection, dict)
242 or not isinstance(projection_path, str)
243 or not projection_path.strip()
244 or projection.get("role") not in _SCHEMA_PROJECTION_ROLES
245 or not isinstance(projection.get("fingerprint"), str)
246 or re.fullmatch(
247 r"[0-9a-f]{12}", projection["fingerprint"]
248 )
249 is None
250 or not isinstance(reason, str)
251 or not reason.strip()
252 or "\n" in reason
253 or "\r" in reason
254 ):
255 raise AuditError(
256 f"{projection_label} requires path, role "
257 "(producer|consumer|reference|compatibility), a 12-hex "
258 "fingerprint, and a one-line reason"
259 )
260 if projection_path in projection_paths:
261 raise AuditError(
262 f"{accepted_label}.projections repeats path "
263 f"{projection_path!r}"
264 )
265 projection_paths.add(projection_path)
266
267 exempt_entries = raw.get("coverage", {}).get("exempt", [])
268 if not isinstance(exempt_entries, list):
269 raise AuditError("coverage.exempt must be an array")
270 for entry in exempt_entries:
271 reason = entry.get("reason") if isinstance(entry, dict) else None
272 if (
273 not isinstance(entry, dict)
274 or not isinstance(entry.get("glob"), str)
275 or not entry["glob"]
276 or not isinstance(reason, str)
277 or not reason.strip()
278 or "\n" in reason
279 or "\r" in reason
280 ):
281 raise AuditError(
282 "coverage.exempt entries require a non-empty glob and one-line reason"
283 )
284
285 accepted_entries = raw.get("duplicates", {}).get("accepted", [])
286 if not isinstance(accepted_entries, list):
287 raise AuditError("duplicates.accepted must be an array")
288 accepted_identities: set[tuple[str, str, tuple[str, ...]]] = set()
289 for entry in accepted_entries:
290 reason = entry.get("reason") if isinstance(entry, dict) else None
291 paths = entry.get("paths") if isinstance(entry, dict) else None
292 if (
293 not isinstance(entry, dict)
294 or entry.get("kind") not in {"exact", "near"}
295 or not isinstance(entry.get("fingerprint"), str)
296 or re.fullmatch(r"[0-9a-f]{12}", entry["fingerprint"]) is None
297 or not isinstance(paths, list)
298 or not paths
299 or not all(isinstance(item, str) and item.strip() for item in paths)
300 or len(set(paths)) != len(paths)
301 or not isinstance(reason, str)
302 or not reason.strip()
303 or "\n" in reason
304 or "\r" in reason
305 ):
306 raise AuditError(
307 "duplicates.accepted entries require kind, 12-hex fingerprint, "
308 "unique paths, and a one-line reason"
309 )
310 identity = (
311 entry["kind"],
312 entry["fingerprint"],
313 tuple(sorted(paths)),
314 )
315 if identity in accepted_identities:
316 raise AuditError(f"Duplicate duplicates.accepted identity: {identity}")
317 accepted_identities.add(identity)
318 return raw
319
320
321 def _expand_globs(
322 root: Path,
323 patterns: Iterable[str],
324 *,
325 require_match: bool,
326 ) -> set[Path]:
327 paths: set[Path] = set()
328 for pattern in patterns:
329 matches = {path for path in root.glob(pattern) if path.is_file()}
330 if require_match and not matches:
331 raise AuditError(f"Document include pattern matched no files: {pattern}")
332 paths.update(matches)
333 return paths
334
335
336 def discover_documents(root: Path, config: dict[str, Any]) -> list[Path]:
337 """Resolve the manifest's document corpus into a stable file list."""
338 include = config.get("include", [])
339 exclude = config.get("exclude", [])
340 if not isinstance(include, list) or not all(isinstance(item, str) for item in include):
341 raise AuditError("documents.include must be a list of path patterns")
342 if not isinstance(exclude, list) or not all(isinstance(item, str) for item in exclude):
343 raise AuditError("documents.exclude must be a list of path patterns")
344
345 paths = _expand_globs(root, include, require_match=True)
346 excluded = _expand_globs(root, exclude, require_match=False) if exclude else set()
347 return sorted(paths - excluded, key=lambda item: _relative_path(root, item))
348
349
350 def _load_encoder(name: str) -> Any:
351 try:
352 import tiktoken
353 except ImportError as exc:
354 raise AuditError(
355 "tiktoken is required for exact prompt counts. "
356 "Install it with: pip install 'tiktoken>=0.7.0'"
357 ) from exc
358
359 try:
360 return tiktoken.get_encoding(name)
361 except (KeyError, ValueError) as exc:
362 raise AuditError(f"tiktoken does not provide the required encoding: {name}") from exc
363
364
365 def count_documents(root: Path, paths: list[Path], encoding_name: str) -> list[Document]:
366 """Read corpus files and count exact tokenizer units."""
367 encoder = _load_encoder(encoding_name)
368 documents: list[Document] = []
369 for path in paths:
370 text = _read_utf8(path)
371 documents.append(
372 Document(
373 path=_relative_path(root, path),
374 absolute_path=path,
375 text=text,
376 tokens=len(encoder.encode(text, disallowed_special=())),
377 )
378 )
379 return documents
380
381
382 def _matches_any(path: str, patterns: Iterable[str]) -> bool:
383 candidate = PurePosixPath(path)
384 return any(candidate.match(pattern) for pattern in patterns)
385
386
387 def _load_entry_paths(root: Path, entry: Any) -> tuple[list[Path], int | None, str]:
388 if isinstance(entry, str):
389 path = root / entry
390 if not path.is_file():
391 raise AuditError(f"Load-set file does not exist: {entry}")
392 return [path], None, entry
393
394 if not isinstance(entry, dict) or not isinstance(entry.get("glob"), str):
395 raise AuditError("Each load-set file entry must be a path or an object with glob")
396
397 pattern = entry["glob"]
398 paths = sorted(root.glob(pattern))
399 paths = [path for path in paths if path.is_file()]
400 excludes = entry.get("exclude", [])
401 if excludes:
402 paths = [
403 path
404 for path in paths
405 if not _matches_any(_relative_path(root, path), excludes)
406 ]
407 if not paths:
408 raise AuditError(f"Load-set selector matched no files: {pattern}")
409
410 select = entry.get("select")
411 if not isinstance(select, int) or select < 1 or select > len(paths):
412 raise AuditError(
413 f"Load-set selector {pattern} has invalid select={select}; "
414 f"expected 1..{len(paths)}"
415 )
416 return paths, select, pattern
417
418
419 def audit_load_sets(
420 root: Path,
421 config: dict[str, Any],
422 token_counts: dict[str, int],
423 manifest_label: str,
424 registry_members: dict[str, set[Any]],
425 ) -> tuple[list[dict[str, Any]], list[Finding], set[str]]:
426 """Resolve declared load scenarios and enforce their maximum budgets."""
427 findings: list[Finding] = []
428 results: list[dict[str, Any]] = []
429 resolved_cache: dict[str, list[Any]] = {}
430 covered_paths: set[str] = set()
431
432 def entry_key(entry: Any) -> str:
433 if isinstance(entry, dict):
434 return json.dumps(entry, sort_keys=True)
435 return f"path:{entry}"
436
437 def resolve_entries(name: str, stack: tuple[str, ...] = ()) -> list[Any]:
438 if name in resolved_cache:
439 return resolved_cache[name]
440 if name not in config:
441 raise AuditError(f"Load set includes unknown set: {name}")
442 if name in stack:
443 cycle = " -> ".join((*stack, name))
444 raise AuditError(f"Load-set include cycle: {cycle}")
445 load_set = config[name]
446 if not isinstance(load_set, dict):
447 raise AuditError(f"load_sets.{name} must be an object")
448 includes = load_set.get("include", [])
449 entries = load_set.get("files", [])
450 if not isinstance(includes, list) or not all(isinstance(item, str) for item in includes):
451 raise AuditError(f"load_sets.{name}.include must be a list of set names")
452 if not isinstance(entries, list):
453 raise AuditError(f"load_sets.{name}.files must be a list")
454
455 resolved: list[Any] = []
456 seen: set[str] = set()
457 for included in includes:
458 for entry in resolve_entries(included, (*stack, name)):
459 key = entry_key(entry)
460 if key not in seen:
461 seen.add(key)
462 resolved.append(entry)
463 for entry in entries:
464 key = entry_key(entry)
465 if key not in seen:
466 seen.add(key)
467 resolved.append(entry)
468 resolved_cache[name] = resolved
469 return resolved
470
471 for name, load_set in sorted(config.items()):
472 if not isinstance(load_set, dict):
473 raise AuditError(f"load_sets.{name} must be an object")
474 entries = resolve_entries(name)
475 budget = load_set.get("max_tokens")
476 if not isinstance(budget, int) or budget < 1:
477 raise AuditError(f"load_sets.{name} requires a positive max_tokens")
478
479 fixed: set[str] = set()
480 selectors: list[dict[str, Any]] = []
481 claimed_options: set[str] = set()
482 for entry in entries:
483 paths, select, label = _load_entry_paths(root, entry)
484 relative = [_relative_path(root, path) for path in paths]
485 covered_paths.update(relative)
486 missing_counts = [path for path in relative if path not in token_counts]
487 if missing_counts:
488 raise AuditError(
489 f"Load set {name} references files outside documents.include: "
490 + ", ".join(missing_counts)
491 )
492
493 if select is None:
494 overlap = (fixed | claimed_options).intersection(relative)
495 if overlap:
496 raise AuditError(
497 f"Load set {name} repeats fixed files: {', '.join(sorted(overlap))}"
498 )
499 fixed.update(relative)
500 continue
501
502 registry_name = entry.get("registry") if isinstance(entry, dict) else None
503 if registry_name is not None:
504 if registry_name not in registry_members:
505 raise AuditError(
506 f"Load set {name} selector references unknown registry: {registry_name}"
507 )
508 candidate_ids = {Path(path).stem for path in relative}
509 expected_ids = {str(item) for item in registry_members[registry_name]}
510 if candidate_ids != expected_ids:
511 missing = sorted(expected_ids - candidate_ids)
512 extra = sorted(candidate_ids - expected_ids)
513 raise AuditError(
514 f"Load set {name} selector does not match registry {registry_name}; "
515 f"missing={missing}, extra={extra}"
516 )
517
518 overlap = claimed_options.intersection(relative) | fixed.intersection(relative)
519 allow_repeat = isinstance(entry, dict) and entry.get("allow_repeat") is True
520 load_event = str(entry.get("load_event", ""))
521 if allow_repeat and not load_event:
522 raise AuditError(
523 f"Load set {name} uses allow_repeat without a named load_event: {label}"
524 )
525 if overlap and not allow_repeat:
526 raise AuditError(
527 f"Load set {name} has overlapping selector files: "
528 + ", ".join(sorted(overlap))
529 )
530 claimed_options.update(relative)
531 counts = sorted(token_counts[path] for path in relative)
532 selectors.append(
533 {
534 "glob": label,
535 "load_event": load_event,
536 "registry": str(registry_name or ""),
537 "select": select,
538 "candidates": len(relative),
539 "min_tokens": sum(counts[:select]),
540 "typical_tokens": round(statistics.mean(counts) * select),
541 "max_tokens": sum(counts[-select:]),
542 }
543 )
544
545 fixed_tokens = sum(token_counts[path] for path in fixed)
546 minimum = fixed_tokens + sum(item["min_tokens"] for item in selectors)
547 typical = fixed_tokens + sum(item["typical_tokens"] for item in selectors)
548 maximum = fixed_tokens + sum(item["max_tokens"] for item in selectors)
549 status = "pass" if maximum <= budget else "fail"
550 if status == "fail":
551 findings.append(
552 Finding(
553 severity="error",
554 code="BUDGET_LOAD_SET",
555 message=f"{name} maximum {maximum} exceeds budget {budget}",
556 path=manifest_label,
557 )
558 )
559 results.append(
560 {
561 "name": name,
562 "description": str(load_set.get("description", "")),
563 "scope": str(load_set.get("scope", "incremental")),
564 "includes": list(load_set.get("include", [])),
565 "fixed_files": sorted(fixed),
566 "selectors": selectors,
567 "tokens": {"min": minimum, "typical": typical, "max": maximum},
568 "max_tokens": budget,
569 "status": status,
570 }
571 )
572 return results, findings, covered_paths
573
574
575 def audit_load_coverage(
576 document_paths: Iterable[str],
577 covered_paths: set[str],
578 config: dict[str, Any],
579 manifest_label: str,
580 ) -> tuple[dict[str, Any], list[Finding]]:
581 """Force every corpus document into a load set or an explicit exemption."""
582 findings: list[Finding] = []
583 exempt_entries = config.get("exempt", [])
584 all_paths = sorted(document_paths)
585 exempt_paths: set[str] = set()
586 exempt_owner: dict[str, str] = {}
587
588 for entry in exempt_entries:
589 glob = entry["glob"]
590 matches = {
591 path for path in all_paths if PurePosixPath(path).match(glob)
592 }
593 if not matches:
594 findings.append(
595 Finding(
596 severity="error",
597 code="COVERAGE_EXEMPT_STALE",
598 message=f"coverage.exempt glob matches no corpus file: {glob}",
599 path=manifest_label,
600 )
601 )
602 continue
603 duplicate_exemptions = sorted(path for path in matches if path in exempt_owner)
604 if duplicate_exemptions:
605 details = ", ".join(
606 f"{path} (already matched by {exempt_owner[path]})"
607 for path in duplicate_exemptions
608 )
609 findings.append(
610 Finding(
611 severity="error",
612 code="COVERAGE_EXEMPT_DUPLICATE",
613 message=f"coverage.exempt glob {glob} overlaps: {details}",
614 path=manifest_label,
615 )
616 )
617 overlap = sorted(matches & covered_paths)
618 if overlap:
619 findings.append(
620 Finding(
621 severity="error",
622 code="COVERAGE_EXEMPT_OVERLAP",
623 message=(
624 f"coverage.exempt glob {glob} matches load-set files: "
625 + ", ".join(overlap)
626 ),
627 path=manifest_label,
628 )
629 )
630 for path in matches:
631 exempt_owner.setdefault(path, glob)
632 exempt_paths.update(matches)
633
634 uncovered = sorted(set(all_paths) - covered_paths - exempt_paths)
635 for path in uncovered:
636 findings.append(
637 Finding(
638 severity="error",
639 code="LOAD_COVERAGE_GAP",
640 message=(
641 "Document is in no load set and has no coverage.exempt entry; "
642 "add it to a load set or exempt it with a reason"
643 ),
644 path=path,
645 )
646 )
647 return (
648 {
649 "documents": len(all_paths),
650 "covered": len(set(all_paths) & covered_paths),
651 "exempt": len(exempt_paths - covered_paths),
652 "uncovered": uncovered,
653 },
654 findings,
655 )
656
657
658 def audit_file_budgets(
659 budgets: dict[str, Any],
660 token_counts: dict[str, int],
661 ) -> list[Finding]:
662 """Check explicit per-file growth ceilings."""
663 findings: list[Finding] = []
664 for path, budget in sorted(budgets.items()):
665 if path not in token_counts:
666 raise AuditError(f"File budget references a file outside the corpus: {path}")
667 if not isinstance(budget, int) or budget < 1:
668 raise AuditError(f"File budget for {path} must be a positive integer")
669 actual = token_counts[path]
670 if actual > budget:
671 findings.append(
672 Finding(
673 severity="error",
674 code="BUDGET_FILE",
675 message=f"File has {actual} tokens; budget is {budget}",
676 path=path,
677 )
678 )
679 return findings
680
681
682 def _duplicate_fingerprint(*texts: str) -> str:
683 joined = "\n\x00\n".join(sorted(texts))
684 return hashlib.sha1(joined.encode("utf-8")).hexdigest()[:12]
685
686
687 def _accepted_identity(
688 kind: str,
689 fingerprint: str,
690 paths: Iterable[str],
691 ) -> tuple[str, str, tuple[str, ...]]:
692 return kind, fingerprint, tuple(sorted(paths))
693
694
695 def _partition_accepted(
696 entries: list[dict[str, Any]],
697 kind: str,
698 accepted: list[dict[str, Any]],
699 used: set[tuple[str, str, tuple[str, ...]]],
700 ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
701 """Split duplicate findings into open ones and manifest-accepted ones."""
702 lookup = {
703 _accepted_identity(item["kind"], item["fingerprint"], item["paths"]): item
704 for item in accepted
705 if item["kind"] == kind
706 }
707 open_entries: list[dict[str, Any]] = []
708 accepted_entries: list[dict[str, Any]] = []
709 for entry in entries:
710 identity = _accepted_identity(kind, entry["fingerprint"], entry["paths"])
711 match = lookup.get(identity)
712 if match is not None:
713 used.add(identity)
714 accepted_entries.append({**entry, "reason": match["reason"]})
715 else:
716 open_entries.append(entry)
717 return open_entries, accepted_entries
718
719
720 def _normalize_paragraph(text: str) -> str:
721 text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
722 text = re.sub(r"[`*_>#|]", " ", text)
723 text = re.sub(r"\s+", " ", text).strip().lower()
724 return text
725
726
727 def extract_paragraphs(
728 documents: list[Document],
729 config: dict[str, Any],
730 ) -> list[Paragraph]:
731 """Extract prose blocks for duplicate and registry analysis."""
732 minimum = int(config.get("min_chars", 100))
733 paragraphs: list[Paragraph] = []
734
735 for document in documents:
736 if not document.path.endswith(".md"):
737 continue
738 block: list[str] = []
739 block_line = 1
740 in_fence = False
741
742 def flush() -> None:
743 nonlocal block
744 text = "\n".join(block).strip()
745 block = []
746 if len(text) < minimum:
747 return
748 normalized = _normalize_paragraph(text)
749 words = tuple(_WORD_RE.findall(normalized))
750 if not normalized or not words:
751 return
752 paragraphs.append(
753 Paragraph(
754 path=document.path,
755 line=block_line,
756 text=text,
757 normalized=normalized,
758 words=words,
759 )
760 )
761
762 for line_number, line in enumerate(document.text.splitlines(), start=1):
763 if _FENCE_RE.match(line):
764 flush()
765 in_fence = not in_fence
766 continue
767 if in_fence:
768 continue
769 if not line.strip() or _HEADING_RE.match(line) or line.strip() == "---":
770 flush()
771 continue
772 if not block:
773 block_line = line_number
774 block.append(line)
775 flush()
776
777 return paragraphs
778
779
780 def extract_registry_blocks(documents: list[Document]) -> list[Paragraph]:
781 """Extract unfiltered Markdown blocks, including headings and fenced examples."""
782 blocks: list[Paragraph] = []
783 for document in documents:
784 if not document.path.endswith(".md"):
785 continue
786 lines: list[str] = []
787 block_line = 1
788
789 def flush() -> None:
790 nonlocal lines
791 text = "\n".join(lines).strip()
792 lines = []
793 if not text:
794 return
795 normalized = _normalize_paragraph(text)
796 blocks.append(
797 Paragraph(
798 path=document.path,
799 line=block_line,
800 text=text,
801 normalized=normalized,
802 words=tuple(_WORD_RE.findall(normalized)),
803 )
804 )
805
806 for line_number, line in enumerate(document.text.splitlines(), start=1):
807 if not line.strip():
808 flush()
809 continue
810 if not lines:
811 block_line = line_number
812 lines.append(line)
813 flush()
814 return blocks
815
816
817 def find_exact_duplicates(
818 paragraphs: list[Paragraph],
819 ) -> tuple[list[dict[str, Any]], int]:
820 """Find normalized prose blocks copied across files."""
821 groups: dict[str, list[Paragraph]] = defaultdict(list)
822 for paragraph in paragraphs:
823 groups[paragraph.normalized].append(paragraph)
824
825 duplicates: list[dict[str, Any]] = []
826 for normalized, items in groups.items():
827 if len({item.path for item in items}) < 2:
828 continue
829 paths = sorted({item.path for item in items})
830 locations = sorted(
831 ({"path": item.path, "line": item.line} for item in items),
832 key=lambda item: (item["path"], item["line"]),
833 )
834 duplicates.append(
835 {
836 "kind": "exact",
837 "fingerprint": _duplicate_fingerprint(*(item.text for item in items)),
838 "paths": paths,
839 "chars": len(normalized),
840 "preview": normalized[:180],
841 "locations": locations,
842 }
843 )
844 duplicates.sort(key=lambda item: (-item["chars"], item["locations"][0]["path"]))
845 return duplicates, len(duplicates)
846
847
848 def find_near_duplicates(
849 paragraphs: list[Paragraph],
850 config: dict[str, Any],
851 ) -> tuple[list[dict[str, Any]], int]:
852 """Find heuristic near-duplicate paragraph candidates using word shingles."""
853 shingle_size = int(config.get("shingle_words", 4))
854 minimum_words = int(config.get("min_words", 20))
855 minimum_hits = int(config.get("min_shared_shingles", 3))
856 max_frequency = int(config.get("max_shingle_frequency", 24))
857 threshold = float(config.get("near_similarity", 0.82))
858
859 eligible = [item for item in paragraphs if len(item.words) >= minimum_words]
860 shingle_sets: list[set[tuple[str, ...]]] = []
861 inverted: dict[tuple[str, ...], list[int]] = defaultdict(list)
862 for index, paragraph in enumerate(eligible):
863 shingles = {
864 paragraph.words[offset : offset + shingle_size]
865 for offset in range(len(paragraph.words) - shingle_size + 1)
866 }
867 shingle_sets.append(shingles)
868 for shingle in shingles:
869 inverted[shingle].append(index)
870
871 hits: Counter[tuple[int, int]] = Counter()
872 for indices in inverted.values():
873 if len(indices) > max_frequency:
874 continue
875 for left_offset, left in enumerate(indices):
876 for right in indices[left_offset + 1 :]:
877 if eligible[left].path != eligible[right].path:
878 hits[(left, right)] += 1
879
880 candidates: list[dict[str, Any]] = []
881 for (left, right), shared in hits.items():
882 if shared < minimum_hits:
883 continue
884 first = eligible[left]
885 second = eligible[right]
886 if first.normalized == second.normalized:
887 continue
888 union = shingle_sets[left] | shingle_sets[right]
889 if not union or shared / len(union) < 0.18:
890 continue
891 similarity = SequenceMatcher(
892 None,
893 first.normalized,
894 second.normalized,
895 autojunk=False,
896 ).ratio()
897 if similarity < threshold:
898 continue
899 candidates.append(
900 {
901 "kind": "near",
902 "fingerprint": _duplicate_fingerprint(first.text, second.text),
903 "paths": sorted((first.path, second.path)),
904 "similarity": round(similarity, 4),
905 "left": {"path": first.path, "line": first.line},
906 "right": {"path": second.path, "line": second.line},
907 "preview": first.normalized[:180],
908 }
909 )
910
911 candidates.sort(
912 key=lambda item: (
913 -item["similarity"],
914 item["left"]["path"],
915 item["left"]["line"],
916 item["right"]["path"],
917 item["right"]["line"],
918 )
919 )
920 return candidates, len(candidates)
921
922
923 def _clean_link_target(raw: str) -> str:
924 target = raw.strip()
925 if target.startswith("<") and ">" in target:
926 target = target[1 : target.index(">")]
927 elif " " in target:
928 target = target.split(" ", 1)[0]
929 return unquote(target.strip())
930
931
932 def extract_references(
933 root: Path,
934 documents: list[Document],
935 ) -> tuple[list[ReferenceEdge], list[Finding]]:
936 """Extract local Markdown links and report missing targets."""
937 edges: list[ReferenceEdge] = []
938 findings: list[Finding] = []
939
940 for document in documents:
941 if not document.path.endswith(".md"):
942 continue
943 in_fence = False
944 for line_number, line in enumerate(document.text.splitlines(), start=1):
945 if _FENCE_RE.match(line):
946 in_fence = not in_fence
947 continue
948 if in_fence:
949 continue
950 for match in _MARKDOWN_LINK_RE.finditer(line):
951 raw_target = _clean_link_target(match.group(1))
952 if (
953 not raw_target
954 or raw_target.startswith("#")
955 or raw_target.startswith(_EXTERNAL_SCHEMES)
956 or "${" in raw_target
957 or "<" in raw_target
958 ):
959 continue
960 target_without_anchor = raw_target.split("#", 1)[0].split("?", 1)[0]
961 if not target_without_anchor:
962 continue
963 target_path = (document.absolute_path.parent / target_without_anchor).resolve()
964 try:
965 target_relative = _relative_path(root, target_path)
966 except ValueError:
967 findings.append(
968 Finding(
969 severity="error",
970 code="REFERENCE_OUTSIDE_ROOT",
971 message=f"Local link escapes repository root: {raw_target}",
972 path=document.path,
973 line=line_number,
974 )
975 )
976 continue
977 if not target_path.exists():
978 findings.append(
979 Finding(
980 severity="error",
981 code="REFERENCE_MISSING",
982 message=f"Markdown target does not exist: {raw_target}",
983 path=document.path,
984 line=line_number,
985 )
986 )
987 continue
988 edges.append(
989 ReferenceEdge(
990 source=document.path,
991 line=line_number,
992 target=target_relative,
993 authority_candidate=bool(_AUTHORITY_TERMS_RE.search(line)),
994 )
995 )
996 edges.sort(key=lambda edge: (edge.source, edge.line, edge.target))
997 return edges, findings
998
999
1000 def _strongly_connected_components(edges: Iterable[tuple[str, str]]) -> list[list[str]]:
1001 graph: dict[str, set[str]] = defaultdict(set)
1002 nodes: set[str] = set()
1003 for source, target in edges:
1004 graph[source].add(target)
1005 nodes.update((source, target))
1006
1007 index = 0
1008 stack: list[str] = []
1009 indices: dict[str, int] = {}
1010 low_links: dict[str, int] = {}
1011 on_stack: set[str] = set()
1012 components: list[list[str]] = []
1013
1014 def visit(node: str) -> None:
1015 nonlocal index
1016 indices[node] = index
1017 low_links[node] = index
1018 index += 1
1019 stack.append(node)
1020 on_stack.add(node)
1021
1022 for neighbor in sorted(graph.get(node, set())):
1023 if neighbor not in indices:
1024 visit(neighbor)
1025 low_links[node] = min(low_links[node], low_links[neighbor])
1026 elif neighbor in on_stack:
1027 low_links[node] = min(low_links[node], indices[neighbor])
1028
1029 if low_links[node] != indices[node]:
1030 return
1031 component: list[str] = []
1032 while stack:
1033 member = stack.pop()
1034 on_stack.remove(member)
1035 component.append(member)
1036 if member == node:
1037 break
1038 if len(component) > 1 or node in graph.get(node, set()):
1039 components.append(sorted(component))
1040
1041 for node in sorted(nodes):
1042 if node not in indices:
1043 visit(node)
1044 return sorted(components, key=lambda item: (len(item), item))
1045
1046
1047 def audit_authority_graph(
1048 root: Path,
1049 edges_config: list[dict[str, Any]],
1050 ) -> tuple[list[dict[str, str]], list[list[str]], list[Finding]]:
1051 """Validate the explicit concern-level authority DAG."""
1052 findings: list[Finding] = []
1053 normalized: list[dict[str, str]] = []
1054 by_concern: dict[str, list[tuple[str, str]]] = defaultdict(list)
1055
1056 for edge in edges_config:
1057 if not isinstance(edge, dict):
1058 raise AuditError("authority_edges entries must be objects")
1059 source = edge.get("from")
1060 target = edge.get("to")
1061 concern = edge.get("concern")
1062 if not all(isinstance(value, str) and value for value in (source, target, concern)):
1063 raise AuditError("authority_edges require non-empty from, to, and concern")
1064 for path in (source, target):
1065 if not (root / path).is_file():
1066 raise AuditError(f"Authority edge references missing file: {path}")
1067 normalized.append({"from": source, "to": target, "concern": concern})
1068 by_concern[concern].append((source, target))
1069
1070 cycles: list[list[str]] = []
1071 for concern, concern_edges in sorted(by_concern.items()):
1072 for component in _strongly_connected_components(concern_edges):
1073 labeled = [f"{concern}:{path}" for path in component]
1074 cycles.append(labeled)
1075 findings.append(
1076 Finding(
1077 severity="error",
1078 code="AUTHORITY_CYCLE",
1079 message=f"Authority cycle for concern {concern}: " + " -> ".join(component),
1080 )
1081 )
1082 normalized.sort(key=lambda item: (item["concern"], item["from"], item["to"]))
1083 return normalized, cycles, findings
1084
1085
1086 def _structured_registry_expected_order(config: dict[str, Any]) -> list[str]:
1087 """Build the canonical browse order for one structured registry."""
1088 prefix_counts = config.get("prefix_counts")
1089 sequence_width = config.get("sequence_width")
1090 if (
1091 not isinstance(prefix_counts, dict)
1092 or not prefix_counts
1093 or not all(
1094 isinstance(prefix, str)
1095 and re.fullmatch(r"[PMAC][1-9]\d*", prefix)
1096 and isinstance(count, int)
1097 and count > 0
1098 for prefix, count in prefix_counts.items()
1099 )
1100 ):
1101 raise AuditError(
1102 f"Registry {config.get('name')} needs positive prefix_counts"
1103 )
1104 if not isinstance(sequence_width, int) or sequence_width < 1:
1105 raise AuditError(
1106 f"Registry {config.get('name')} needs a positive sequence_width"
1107 )
1108 return [
1109 f"{prefix}-{sequence:0{sequence_width}d}"
1110 for prefix, count in prefix_counts.items()
1111 for sequence in range(1, count + 1)
1112 ]
1113
1114
1115 def _registry_ids(
1116 root: Path,
1117 config: dict[str, Any],
1118 ) -> tuple[set[Any], str, list[Any], list[Any] | None]:
1119 kind = config.get("kind")
1120 source = config.get("source")
1121 if not isinstance(source, str) or not (root / source).is_file():
1122 raise AuditError(f"Registry has missing source: {source}")
1123
1124 if kind == "structured_markdown":
1125 pattern = config.get("entry_pattern")
1126 if not isinstance(pattern, str):
1127 raise AuditError(f"Registry {config.get('name')} needs entry_pattern")
1128 try:
1129 regex = re.compile(pattern, re.MULTILINE)
1130 except re.error as exc:
1131 raise AuditError(f"Invalid registry entry_pattern: {exc}") from exc
1132 if "id" not in regex.groupindex:
1133 raise AuditError(
1134 f"Registry {config.get('name')} entry_pattern needs an id group"
1135 )
1136 matched_ids = [
1137 match.group("id")
1138 for match in regex.finditer(_read_utf8(root / source))
1139 ]
1140 duplicates = sorted(
1141 item for item, count in Counter(matched_ids).items() if count > 1
1142 )
1143 return set(matched_ids), source, duplicates, matched_ids
1144
1145 if kind == "directory":
1146 pattern = config.get("glob")
1147 excludes = config.get("exclude", [])
1148 if not isinstance(pattern, str):
1149 raise AuditError(f"Registry {config.get('name')} needs glob")
1150 paths = [path for path in root.glob(pattern) if path.is_file()]
1151 ids = {
1152 path.stem
1153 for path in paths
1154 if not _matches_any(_relative_path(root, path), excludes)
1155 }
1156 return ids, source, [], None
1157
1158 if kind == "json_collection":
1159 key = config.get("key")
1160 if not isinstance(key, str):
1161 raise AuditError(f"Registry {config.get('name')} needs key")
1162 try:
1163 value: Any = json.loads(_read_utf8(root / source))
1164 except json.JSONDecodeError as exc:
1165 raise AuditError(f"Registry source is invalid JSON: {source}: {exc}") from exc
1166 for part in key.split("."):
1167 if not isinstance(value, dict) or part not in value:
1168 raise AuditError(f"Registry key {key} is missing in {source}")
1169 value = value[part]
1170 if isinstance(value, dict):
1171 return set(value), source, [], None
1172 if isinstance(value, list):
1173 id_field = config.get("id_field")
1174 if id_field is not None:
1175 if not isinstance(id_field, str) or not id_field:
1176 raise AuditError(
1177 f"Registry {config.get('name')} id_field must be a non-empty string"
1178 )
1179 matched_ids: list[str] = []
1180 for index, item in enumerate(value):
1181 if not isinstance(item, dict):
1182 raise AuditError(
1183 f"Registry {config.get('name')} item {index} is not an object"
1184 )
1185 item_id = item.get(id_field)
1186 if not isinstance(item_id, str) or not item_id.strip():
1187 raise AuditError(
1188 f"Registry {config.get('name')} item {index} has no "
1189 f"non-empty {id_field!r}"
1190 )
1191 matched_ids.append(item_id.strip())
1192 duplicates = sorted(
1193 item
1194 for item, count in Counter(matched_ids).items()
1195 if count > 1
1196 )
1197 return set(matched_ids), source, duplicates, matched_ids
1198 return set(range(len(value))), source, [], None
1199 raise AuditError(f"Registry key {key} in {source} is not a collection")
1200
1201 if kind == "line_collection":
1202 values = [
1203 line.strip()
1204 for line in _read_utf8(root / source).splitlines()
1205 if line.strip()
1206 ]
1207 duplicates = sorted(
1208 item for item, count in Counter(values).items() if count > 1
1209 )
1210 return set(values), source, duplicates, values
1211
1212 raise AuditError(f"Unsupported registry kind: {kind}")
1213
1214
1215 def _registry_projection_ids(
1216 root: Path,
1217 registry_name: str,
1218 config: dict[str, Any],
1219 ) -> tuple[set[str], str, list[str]] | None:
1220 """Extract one declared Markdown projection of canonical registry ids."""
1221 projection = config.get("projection")
1222 if projection is None:
1223 return None
1224 if not isinstance(projection, dict):
1225 raise AuditError(
1226 f"Registry {registry_name} projection must be an object"
1227 )
1228 source = projection.get("source")
1229 if not isinstance(source, str) or not (root / source).is_file():
1230 raise AuditError(
1231 f"Registry {registry_name} projection has missing source: {source}"
1232 )
1233 pattern = projection.get("entry_pattern")
1234 if not isinstance(pattern, str):
1235 raise AuditError(
1236 f"Registry {registry_name} projection needs entry_pattern"
1237 )
1238 try:
1239 regex = re.compile(pattern, re.MULTILINE)
1240 except re.error as exc:
1241 raise AuditError(
1242 f"Invalid registry projection entry_pattern: {exc}"
1243 ) from exc
1244 if "id" not in regex.groupindex:
1245 raise AuditError(
1246 f"Registry {registry_name} projection entry_pattern needs an id group"
1247 )
1248 matched_ids = [
1249 match.group("id")
1250 for match in regex.finditer(_read_utf8(root / source))
1251 ]
1252 duplicates = sorted(
1253 item for item, count in Counter(matched_ids).items() if count > 1
1254 )
1255 return set(matched_ids), source, duplicates
1256
1257
1258 def _registry_count_claims(paragraph: Paragraph, nouns: list[str]) -> list[int]:
1259 noun_pattern = "|".join(re.escape(noun) for noun in nouns)
1260 patterns = (
1261 rf"\bcatalog\s*\(\s*(\d+)\s+(?:{noun_pattern})\s*\)",
1262 rf"\bcatalog\s+read\s*:?\s*(\d+)\s+(?:{noun_pattern})\b",
1263 rf"\(\s*(\d+)\s+(?:{noun_pattern})\s*\)",
1264 rf"\b(?:all|every|the|total(?:\s+of)?|contains?|currently(?:\s+has)?|read(?:\s+all)?)\s+"
1265 rf"(\d+)\s+(?:{noun_pattern})\b",
1266 )
1267 claims: list[int] = []
1268 for pattern in patterns:
1269 matches = re.finditer(pattern, paragraph.normalized, re.I)
1270 claims.extend(int(match.group(1)) for match in matches)
1271 return claims
1272
1273
1274 def audit_registries(
1275 root: Path,
1276 configs: list[dict[str, Any]],
1277 paragraphs: list[Paragraph],
1278 documents: list[Document],
1279 ) -> tuple[list[dict[str, Any]], list[Finding]]:
1280 """Compare live registry membership with documentation claims."""
1281 findings: list[Finding] = []
1282 reports: list[dict[str, Any]] = []
1283
1284 for config in configs:
1285 name = config.get("name")
1286 if not isinstance(name, str) or not name:
1287 raise AuditError("Every registry requires a name")
1288 ids, source, duplicate_ids, source_order = _registry_ids(root, config)
1289 if not ids:
1290 raise AuditError(f"Registry {name} has no entries")
1291 for duplicate_id in duplicate_ids:
1292 findings.append(
1293 Finding(
1294 severity="error",
1295 code="REGISTRY_ID_DUPLICATE",
1296 message=f"{name} defines id {duplicate_id!r} more than once",
1297 path=source,
1298 )
1299 )
1300 projection = _registry_projection_ids(root, name, config)
1301 projection_source: str | None = None
1302 projection_ids: set[str] | None = None
1303 projection_duplicates: list[str] = []
1304 if projection is not None:
1305 projection_ids, projection_source, projection_duplicates = projection
1306 for duplicate_id in projection_duplicates:
1307 findings.append(
1308 Finding(
1309 severity="error",
1310 code="REGISTRY_PROJECTION_ID_DUPLICATE",
1311 message=(
1312 f"{name} projection repeats id {duplicate_id!r}"
1313 ),
1314 path=projection_source,
1315 )
1316 )
1317 canonical_ids = {str(item) for item in ids}
1318 missing_ids = sorted(canonical_ids - projection_ids)
1319 extra_ids = sorted(projection_ids - canonical_ids)
1320 if missing_ids or extra_ids:
1321 findings.append(
1322 Finding(
1323 severity="error",
1324 code="REGISTRY_PROJECTION_MISMATCH",
1325 message=(
1326 f"{name} projection differs from its registry; "
1327 f"missing={missing_ids}, extra={extra_ids}"
1328 ),
1329 path=projection_source,
1330 )
1331 )
1332 expected_order: list[str] | None = None
1333 expected_ids: set[str] | None = None
1334 if config.get("kind") == "structured_markdown":
1335 expected_order = _structured_registry_expected_order(config)
1336 expected_ids = set(expected_order)
1337 string_ids = {str(item) for item in ids}
1338 missing_ids = sorted(expected_ids - string_ids)
1339 extra_ids = sorted(string_ids - expected_ids)
1340 if missing_ids:
1341 findings.append(
1342 Finding(
1343 severity="error",
1344 code="REGISTRY_IDS_MISSING",
1345 message=(
1346 f"{name} is missing canonical ids: "
1347 + ", ".join(missing_ids)
1348 ),
1349 path=source,
1350 )
1351 )
1352 if (
1353 not missing_ids
1354 and not extra_ids
1355 and not duplicate_ids
1356 and source_order != expected_order
1357 ):
1358 mismatch = next(
1359 index
1360 for index, (actual, expected) in enumerate(
1361 zip(source_order or [], expected_order)
1362 )
1363 if actual != expected
1364 )
1365 findings.append(
1366 Finding(
1367 severity="error",
1368 code="REGISTRY_ID_ORDER",
1369 message=(
1370 f"{name} browse order expects {expected_order[mismatch]} "
1371 f"at position {mismatch + 1}; found {source_order[mismatch]}"
1372 ),
1373 path=source,
1374 )
1375 )
1376 if extra_ids:
1377 findings.append(
1378 Finding(
1379 severity="error",
1380 code="REGISTRY_IDS_EXTRA",
1381 message=(
1382 f"{name} defines unexpected canonical ids: "
1383 + ", ".join(extra_ids)
1384 ),
1385 path=source,
1386 )
1387 )
1388 index_labels: list[str] | None = None
1389 index_targets: list[str] | None = None
1390 if config.get("validate_index_links") is True:
1391 if config.get("kind") != "directory":
1392 raise AuditError(
1393 f"Registry {name} enables validate_index_links but is not a directory"
1394 )
1395 index_matches = list(
1396 _REGISTRY_INDEX_ENTRY_RE.finditer(_read_utf8(root / source))
1397 )
1398 index_labels = [match.group("label") for match in index_matches]
1399 index_targets = [match.group("target") for match in index_matches]
1400 for label, target in zip(index_labels, index_targets):
1401 if label != target:
1402 findings.append(
1403 Finding(
1404 severity="error",
1405 code="REGISTRY_INDEX_LABEL_TARGET_MISMATCH",
1406 message=(
1407 f"{name} index label {label!r} points to {target!r}"
1408 ),
1409 path=source,
1410 )
1411 )
1412 for field_name, values in (
1413 ("label", index_labels),
1414 ("target", index_targets),
1415 ):
1416 duplicates = sorted(
1417 value for value, count in Counter(values).items() if count > 1
1418 )
1419 if duplicates:
1420 findings.append(
1421 Finding(
1422 severity="error",
1423 code="REGISTRY_INDEX_DUPLICATE",
1424 message=(
1425 f"{name} index repeats {field_name}(s): "
1426 + ", ".join(duplicates)
1427 ),
1428 path=source,
1429 )
1430 )
1431 index_ids = set(values)
1432 if index_ids != ids:
1433 missing = sorted(str(item) for item in ids - index_ids)
1434 extra = sorted(str(item) for item in index_ids - ids)
1435 findings.append(
1436 Finding(
1437 severity="error",
1438 code="REGISTRY_INDEX_MISMATCH",
1439 message=(
1440 f"{name} index {field_name}s differ from registry files; "
1441 f"missing={missing}, extra={extra}"
1442 ),
1443 path=source,
1444 )
1445 )
1446 terms = [_normalize_paragraph(str(item)) for item in config.get("reference_terms", [])]
1447 source_name = Path(source).name
1448 if not source_name.startswith("_"):
1449 terms.append(_normalize_paragraph(source_name))
1450 nouns = [str(item).lower() for item in config.get("claim_nouns", [])]
1451 claims: list[dict[str, Any]] = []
1452 seen_claims: set[tuple[str, int, int]] = set()
1453 line_claim_keys: set[tuple[str, int]] = set()
1454
1455 def record_count_claim(path: str, line: int, count: int) -> None:
1456 key = (path, line, count)
1457 if key in seen_claims:
1458 return
1459 seen_claims.add(key)
1460 claims.append({"path": path, "line": line, "count": count})
1461 if count != len(ids):
1462 findings.append(
1463 Finding(
1464 severity="error",
1465 code="REGISTRY_COUNT_MISMATCH",
1466 message=f"{name} claims {count} entries; registry contains {len(ids)}",
1467 path=path,
1468 line=line,
1469 )
1470 )
1471
1472 for document in documents:
1473 if not document.path.endswith(".md"):
1474 continue
1475 for line_number, line in enumerate(document.text.splitlines(), start=1):
1476 normalized = _normalize_paragraph(line)
1477 if document.path != source and not any(term in normalized for term in terms):
1478 continue
1479 claim_line = Paragraph(
1480 path=document.path,
1481 line=line_number,
1482 text=line,
1483 normalized=normalized,
1484 words=tuple(_WORD_RE.findall(normalized)),
1485 )
1486 for count in _registry_count_claims(claim_line, nouns):
1487 line_claim_keys.add((document.path, count))
1488 record_count_claim(document.path, line_number, count)
1489
1490 for paragraph in paragraphs:
1491 registry_context = paragraph.path == source or any(
1492 term in paragraph.normalized for term in terms
1493 )
1494 if registry_context:
1495 for count in _registry_count_claims(paragraph, nouns):
1496 if (paragraph.path, count) not in line_claim_keys:
1497 record_count_claim(paragraph.path, paragraph.line, count)
1498 kind = config.get("kind")
1499 if kind == "structured_markdown":
1500 canonical_ids = {str(item) for item in ids}
1501 structured_pattern = re.compile(r"#([PMAC][1-9]\d*-\d+)\b")
1502 for match in structured_pattern.finditer(paragraph.text):
1503 claimed_id = match.group(1)
1504 if claimed_id not in canonical_ids:
1505 findings.append(
1506 Finding(
1507 severity="error",
1508 code="REGISTRY_ID_MISSING",
1509 message=(
1510 f"{name} references missing id #{claimed_id}"
1511 ),
1512 path=paragraph.path,
1513 line=paragraph.line,
1514 )
1515 )
1516 legacy_named_pattern = re.compile(
1517 r"#(?P<id>"
1518 r"(?:single|canvas|multi|reveal|tone|depth|asset|continuity)_\d+"
1519 r")\b"
1520 )
1521 for match in legacy_named_pattern.finditer(paragraph.text):
1522 findings.append(
1523 Finding(
1524 severity="error",
1525 code="REGISTRY_ID_LEGACY",
1526 message=(
1527 f"{name} uses removed id #{match.group('id')}"
1528 ),
1529 path=paragraph.path,
1530 line=paragraph.line,
1531 )
1532 )
1533 if registry_context:
1534 legacy_numeric_pattern = re.compile(r"#(?P<id>[1-9]\d*)\b")
1535 for match in legacy_numeric_pattern.finditer(paragraph.text):
1536 findings.append(
1537 Finding(
1538 severity="error",
1539 code="REGISTRY_ID_LEGACY",
1540 message=(
1541 f"{name} uses removed id #{match.group('id')}"
1542 ),
1543 path=paragraph.path,
1544 line=paragraph.line,
1545 )
1546 )
1547 continue
1548
1549 reports.append(
1550 {
1551 "name": name,
1552 "source": source,
1553 "entries": len(ids),
1554 "duplicate_ids": duplicate_ids,
1555 "minimum_id": min(ids) if all(isinstance(item, int) for item in ids) else None,
1556 "maximum_id": max(ids) if all(isinstance(item, int) for item in ids) else None,
1557 "expected_entries": len(expected_ids) if expected_ids is not None else None,
1558 "index_labels": index_labels,
1559 "index_targets": index_targets,
1560 "projection": (
1561 {
1562 "source": projection_source,
1563 "entries": len(projection_ids),
1564 "duplicate_ids": projection_duplicates,
1565 }
1566 if projection_ids is not None
1567 else None
1568 ),
1569 "claims": sorted(claims, key=lambda item: (item["path"], item["line"])),
1570 }
1571 )
1572 return sorted(reports, key=lambda item: item["name"]), findings
1573
1574
1575 def _schema_fingerprint(payload: Any) -> str:
1576 serialized = json.dumps(
1577 payload,
1578 ensure_ascii=False,
1579 sort_keys=True,
1580 separators=(",", ":"),
1581 )
1582 return hashlib.sha1(serialized.encode("utf-8")).hexdigest()[:12]
1583
1584
1585 def _schema_field_pattern(schema_field: str) -> re.Pattern[str]:
1586 return re.compile(
1587 rf"(?<![A-Za-z0-9_]){re.escape(schema_field)}(?![A-Za-z0-9_])"
1588 )
1589
1590
1591 def _schema_owner_fragments(value: Any, schema_field: str) -> list[dict[str, Any]]:
1592 """Extract stable, field-local fragments from a JSON schema owner."""
1593 field_re = _schema_field_pattern(schema_field)
1594 negative_field_re = re.compile(
1595 rf"\(\?!{re.escape(schema_field)}(?:\\?\$)?\)"
1596 )
1597 fragments: list[dict[str, Any]] = []
1598
1599 def walk(item: Any, path: tuple[str, ...]) -> None:
1600 if isinstance(item, dict):
1601 direct_rule = any(
1602 isinstance(child, str)
1603 and field_re.search(negative_field_re.sub("", child))
1604 for key, child in item.items()
1605 if key != schema_field
1606 )
1607 if direct_rule:
1608 fragments.append(
1609 {
1610 "kind": "rule",
1611 "path": "/".join(path),
1612 "value": item,
1613 }
1614 )
1615 return
1616
1617 for key, child in item.items():
1618 child_path = (*path, str(key))
1619 if key == schema_field:
1620 fragments.append(
1621 {
1622 "kind": "definition",
1623 "path": "/".join(child_path),
1624 "value": child,
1625 }
1626 )
1627 continue
1628 walk(child, child_path)
1629 return
1630
1631 if not isinstance(item, list):
1632 return
1633 for child in item:
1634 if isinstance(child, str) and child == schema_field:
1635 fragments.append(
1636 {
1637 "kind": "membership",
1638 "path": "/".join(path),
1639 "value": child,
1640 }
1641 )
1642 continue
1643 child_path = path
1644 if isinstance(child, dict):
1645 semantic_id = child.get("id")
1646 if isinstance(semantic_id, str) and semantic_id:
1647 child_path = (*path, f"id={semantic_id}")
1648 walk(child, child_path)
1649
1650 walk(value, ())
1651 unique = {
1652 json.dumps(
1653 fragment,
1654 ensure_ascii=False,
1655 sort_keys=True,
1656 separators=(",", ":"),
1657 ): fragment
1658 for fragment in fragments
1659 }
1660 return [unique[key] for key in sorted(unique)]
1661
1662
1663 def _schema_owner_fingerprint(
1664 source_path: Path,
1665 source_text: str,
1666 schema_field: str,
1667 ) -> str:
1668 """Fingerprint only the owner's field-related contract fragments."""
1669 if source_path.suffix.casefold() == ".json":
1670 try:
1671 payload = json.loads(source_text)
1672 except json.JSONDecodeError as exc:
1673 raise AuditError(f"Invalid JSON schema owner {source_path}: {exc}") from exc
1674
1675 fragments = _schema_owner_fragments(payload, schema_field)
1676 if not fragments:
1677 raise AuditError(
1678 f"Schema owner {source_path} has no fingerprintable contract "
1679 f"for {schema_field}"
1680 )
1681 return _schema_fingerprint(fragments)
1682
1683 field_re = _schema_field_pattern(schema_field)
1684 lines: list[str] = []
1685 in_fence = False
1686 for line in source_text.splitlines():
1687 if _FENCE_RE.match(line):
1688 in_fence = not in_fence
1689 continue
1690 if not in_fence and field_re.search(line):
1691 lines.append(line.strip())
1692 if not lines:
1693 raise AuditError(
1694 f"Schema owner {source_path} has no fingerprintable contract "
1695 f"for {schema_field}"
1696 )
1697 return _schema_fingerprint(lines)
1698
1699
1700 def _has_schema_grammar_signal(line: str, schema_field: str) -> bool:
1701 """Detect field-local grammar text without treating unrelated key prose as syntax."""
1702 heading = _SCHEMA_HEADING_RE.match(line)
1703 if heading and heading.group(1) == schema_field:
1704 return True
1705
1706 field_re = _schema_field_pattern(schema_field)
1707 for match in field_re.finditer(line):
1708 before = line[max(0, match.start() - 160) : match.start()]
1709 after = line[match.end() : match.end() + 240]
1710 context = f"{before} {schema_field} {after}"
1711 if re.match(r'''\s*[`*'\"]*\s*[:=]''', after):
1712 return True
1713 if re.search(
1714 r"\b(?:"
1715 r"grammars?|syntaxes?|formats?|schemas?|key formats?|"
1716 r"allowed values?|one of|uses? one|accepts? only|followed by"
1717 r")\b",
1718 context,
1719 re.I,
1720 ):
1721 return True
1722 if re.search(r"\bas\s+`?<[^>]+>", context, re.I):
1723 return True
1724 if re.search(r"(?:P<NN>|<[^>\n]+>)\s*:", context):
1725 return True
1726 if re.search(
1727 r"\b(?:writes?|written|records?|recorded|declares?|declared|"
1728 r"projects?|projected|assigns?|assigned|emits?|emitted|authors?|"
1729 r"authored)\b.{0,140}$",
1730 before,
1731 re.I,
1732 ):
1733 return True
1734 if re.search(
1735 r"^.{0,140}\b(?:is|are|must be|may be)?\s*(?:written|recorded|"
1736 r"declared|projected|assigned|emitted|authored)\b",
1737 after,
1738 re.I,
1739 ):
1740 return True
1741 return False
1742
1743
1744 def _schema_projection(
1745 schema_field: str,
1746 path: str,
1747 sites: list[dict[str, Any]],
1748 ) -> dict[str, Any]:
1749 fingerprint = _schema_fingerprint(
1750 {
1751 "field": schema_field,
1752 "path": path,
1753 "lines": [site["text"] for site in sites],
1754 }
1755 )
1756 return {
1757 "path": path,
1758 "fingerprint": fingerprint,
1759 "definition_candidates": [
1760 {
1761 "line": site["line"],
1762 "excerpt": site["text"].strip()[:240],
1763 }
1764 for site in sites
1765 ],
1766 }
1767
1768
1769 def audit_schema_grammars(
1770 root: Path,
1771 configs: list[dict[str, Any]],
1772 documents: list[Document],
1773 manifest_label: str,
1774 ) -> tuple[list[dict[str, Any]], list[Finding]]:
1775 """Surface open grammar projections and validate accepted projections."""
1776 findings: list[Finding] = []
1777 results: list[dict[str, Any]] = []
1778 document_map = {document.path: document for document in documents}
1779
1780 for config in configs:
1781 source = config.get("source")
1782 if not isinstance(source, str) or not (root / source).is_file():
1783 raise AuditError(f"Schema source does not exist: {source}")
1784 source_text = _read_utf8(root / source)
1785 configured_fields = config.get("fields")
1786 if configured_fields is None:
1787 fields = sorted(set(_SCHEMA_HEADING_RE.findall(source_text)))
1788 elif isinstance(configured_fields, list):
1789 fields = sorted(str(item) for item in configured_fields)
1790 else:
1791 raise AuditError("schema_grammars fields must be a list when present")
1792 scan_patterns = config.get("scan", ["skills/ppt-master/**/*.md"])
1793 accepted_by_field = {
1794 str(entry["field"]): entry for entry in config.get("accepted", [])
1795 }
1796 processed_accepted_fields: set[str] = set()
1797
1798 for schema_field in fields:
1799 owner_defines_field = any(
1800 re.search(
1801 rf"(?<![A-Za-z0-9_]){re.escape(schema_field)}(?![A-Za-z0-9_])",
1802 line,
1803 )
1804 and _has_schema_grammar_signal(line, schema_field)
1805 for line in source_text.splitlines()
1806 )
1807 if not owner_defines_field:
1808 raise AuditError(
1809 f"Schema owner {source} does not define configured field {schema_field}"
1810 )
1811 owner_fingerprint = _schema_owner_fingerprint(
1812 root / source,
1813 source_text,
1814 schema_field,
1815 )
1816 sites_by_path: dict[str, list[dict[str, Any]]] = defaultdict(list)
1817 field_re = re.compile(rf"(?<![A-Za-z0-9_]){re.escape(schema_field)}(?![A-Za-z0-9_])")
1818 for path, document in sorted(document_map.items()):
1819 if path == source or not _matches_any(path, scan_patterns):
1820 continue
1821 in_fence = False
1822 for line_number, line in enumerate(document.text.splitlines(), start=1):
1823 if _FENCE_RE.match(line):
1824 in_fence = not in_fence
1825 continue
1826 if in_fence or not field_re.search(line):
1827 continue
1828 if not _has_schema_grammar_signal(line, schema_field):
1829 continue
1830 sites_by_path[path].append(
1831 {
1832 "line": line_number,
1833 "text": line,
1834 }
1835 )
1836
1837 projections = {
1838 path: _schema_projection(schema_field, path, sites)
1839 for path, sites in sorted(sites_by_path.items())
1840 }
1841 accepted_config = accepted_by_field.get(schema_field)
1842 if accepted_config is not None:
1843 processed_accepted_fields.add(schema_field)
1844 owner_matches = bool(
1845 accepted_config is not None
1846 and accepted_config["owner_fingerprint"] == owner_fingerprint
1847 )
1848 if accepted_config is not None and not owner_matches:
1849 findings.append(
1850 Finding(
1851 severity="error",
1852 code="SCHEMA_ACCEPTED_STALE",
1853 message=(
1854 f"schema_grammars accepted owner fingerprint for "
1855 f"{schema_field} is stale; expected "
1856 f"{accepted_config['owner_fingerprint']}, current "
1857 f"{owner_fingerprint}"
1858 ),
1859 path=manifest_label,
1860 related=[source],
1861 )
1862 )
1863
1864 configured_projections = {
1865 projection["path"]: projection
1866 for projection in (
1867 accepted_config.get("projections", [])
1868 if accepted_config is not None
1869 else []
1870 )
1871 }
1872 open_projections: list[dict[str, Any]] = []
1873 accepted_projections: list[dict[str, Any]] = []
1874 stale_projections: list[dict[str, Any]] = []
1875 for path, projection in projections.items():
1876 configured = configured_projections.get(path)
1877 projection_matches = bool(
1878 configured is not None
1879 and configured["fingerprint"] == projection["fingerprint"]
1880 )
1881 if owner_matches and projection_matches:
1882 accepted_projections.append(
1883 {
1884 **projection,
1885 "role": configured["role"],
1886 "reason": configured["reason"],
1887 }
1888 )
1889 continue
1890
1891 if configured is None:
1892 open_projections.append(projection)
1893 first_line = projection["definition_candidates"][0]["line"]
1894 findings.append(
1895 Finding(
1896 severity="warning",
1897 code="SCHEMA_MULTIDEF_CANDIDATE",
1898 message=(
1899 f"{source} owns {schema_field}, but this file carries "
1900 "an unaccepted grammar-like projection"
1901 ),
1902 path=path,
1903 line=first_line,
1904 related=[source],
1905 )
1906 )
1907 continue
1908
1909 stale_projections.append(
1910 {
1911 **projection,
1912 "role": configured["role"],
1913 "reason": configured["reason"],
1914 "expected_fingerprint": configured["fingerprint"],
1915 "stale_reason": (
1916 "owner_fingerprint" if not owner_matches else "fingerprint"
1917 ),
1918 }
1919 )
1920 if owner_matches and not projection_matches:
1921 findings.append(
1922 Finding(
1923 severity="error",
1924 code="SCHEMA_ACCEPTED_STALE",
1925 message=(
1926 f"schema_grammars accepted projection fingerprint "
1927 f"for {schema_field} at {path} is stale; expected "
1928 f"{configured['fingerprint']}, current "
1929 f"{projection['fingerprint']}"
1930 ),
1931 path=manifest_label,
1932 related=[source, path],
1933 )
1934 )
1935
1936 if owner_matches:
1937 for path, configured in configured_projections.items():
1938 if path in projections:
1939 continue
1940 stale_projections.append(
1941 {
1942 "path": path,
1943 "fingerprint": None,
1944 "definition_candidates": [],
1945 "role": configured["role"],
1946 "reason": configured["reason"],
1947 "expected_fingerprint": configured["fingerprint"],
1948 "stale_reason": "missing",
1949 }
1950 )
1951 findings.append(
1952 Finding(
1953 severity="error",
1954 code="SCHEMA_ACCEPTED_STALE",
1955 message=(
1956 f"schema_grammars accepted projection for "
1957 f"{schema_field} at {path} no longer exists"
1958 ),
1959 path=manifest_label,
1960 related=[source, path],
1961 )
1962 )
1963
1964 if not projections and accepted_config is None:
1965 continue
1966 results.append(
1967 {
1968 "field": schema_field,
1969 "owner": source,
1970 "owner_fingerprint": owner_fingerprint,
1971 "open": open_projections,
1972 "accepted": accepted_projections,
1973 "stale": stale_projections,
1974 }
1975 )
1976
1977 for schema_field in sorted(
1978 set(accepted_by_field) - processed_accepted_fields
1979 ):
1980 findings.append(
1981 Finding(
1982 severity="error",
1983 code="SCHEMA_ACCEPTED_STALE",
1984 message=(
1985 f"schema_grammars accepted field {schema_field} is no "
1986 "longer configured for this owner"
1987 ),
1988 path=manifest_label,
1989 related=[source],
1990 )
1991 )
1992 results.sort(key=lambda item: item["field"])
1993 return results, findings
1994
1995
1996 def _finding_key(finding: Finding) -> tuple[Any, ...]:
1997 return (
1998 _SEVERITY_ORDER.get(finding.severity, 9),
1999 finding.code,
2000 finding.path,
2001 finding.line,
2002 finding.message,
2003 )
2004
2005
2006 def run_audit(
2007 root: Path,
2008 manifest_path: Path,
2009 *,
2010 include_near_duplicates: bool = True,
2011 ) -> dict[str, Any]:
2012 """Run the complete read-only prompt audit and return a stable report."""
2013 root = root.resolve()
2014 manifest = load_manifest(manifest_path)
2015 encoding_name = str(manifest["encoding"])
2016 paths = discover_documents(root, manifest["documents"])
2017 documents = count_documents(root, paths, encoding_name)
2018 token_counts = {document.path: document.tokens for document in documents}
2019 findings: list[Finding] = []
2020 try:
2021 manifest_label = _relative_path(root, manifest_path)
2022 except ValueError:
2023 manifest_label = str(manifest_path.resolve())
2024
2025 corpus_budget = manifest["documents"].get("max_tokens")
2026 corpus_tokens = sum(token_counts.values())
2027 if isinstance(corpus_budget, int) and corpus_tokens > corpus_budget:
2028 findings.append(
2029 Finding(
2030 severity="error",
2031 code="BUDGET_CORPUS",
2032 message=f"Corpus has {corpus_tokens} tokens; budget is {corpus_budget}",
2033 )
2034 )
2035
2036 findings.extend(audit_file_budgets(manifest.get("file_budgets", {}), token_counts))
2037 registry_configs = manifest.get("registries", [])
2038 registry_members: dict[str, set[Any]] = {}
2039 for registry_config in registry_configs:
2040 registry_name = registry_config.get("name")
2041 if not isinstance(registry_name, str) or not registry_name:
2042 raise AuditError("Every registry requires a name")
2043 if registry_name in registry_members:
2044 raise AuditError(f"Duplicate registry name: {registry_name}")
2045 members, _, _, _ = _registry_ids(root, registry_config)
2046 registry_members[registry_name] = members
2047
2048 load_sets, load_findings, covered_paths = audit_load_sets(
2049 root,
2050 manifest["load_sets"],
2051 token_counts,
2052 manifest_label,
2053 registry_members,
2054 )
2055 findings.extend(load_findings)
2056
2057 coverage, coverage_findings = audit_load_coverage(
2058 token_counts.keys(),
2059 covered_paths,
2060 manifest.get("coverage", {}),
2061 manifest_label,
2062 )
2063 findings.extend(coverage_findings)
2064
2065 duplicate_config = manifest.get("duplicates", {})
2066 accepted_config = duplicate_config.get("accepted", [])
2067 accepted_used: set[tuple[str, str, tuple[str, ...]]] = set()
2068 paragraphs = extract_paragraphs(documents, duplicate_config)
2069 registry_blocks = extract_registry_blocks(documents)
2070 exact, _ = find_exact_duplicates(paragraphs)
2071 exact, exact_accepted = _partition_accepted(
2072 exact,
2073 "exact",
2074 accepted_config,
2075 accepted_used,
2076 )
2077 exact_total = len(exact)
2078 exact = exact[: int(duplicate_config.get("max_exact_results", 100))]
2079 if include_near_duplicates:
2080 near, _ = find_near_duplicates(paragraphs, duplicate_config)
2081 near, near_accepted = _partition_accepted(
2082 near,
2083 "near",
2084 accepted_config,
2085 accepted_used,
2086 )
2087 near_total = len(near)
2088 near = near[: int(duplicate_config.get("max_near_results", 100))]
2089 else:
2090 near, near_total, near_accepted = [], None, []
2091
2092 scanned_duplicate_kinds = {"exact"}
2093 if include_near_duplicates:
2094 scanned_duplicate_kinds.add("near")
2095 for entry in accepted_config:
2096 identity = _accepted_identity(
2097 entry["kind"],
2098 entry["fingerprint"],
2099 entry["paths"],
2100 )
2101 if entry["kind"] in scanned_duplicate_kinds and identity not in accepted_used:
2102 findings.append(
2103 Finding(
2104 severity="error",
2105 code="DUPLICATE_ACCEPTED_STALE",
2106 message=(
2107 "duplicates.accepted entry matches no current duplicate; "
2108 f"remove or update {entry['kind']} {entry['fingerprint']} "
2109 f"for {entry['paths']}"
2110 ),
2111 path=manifest_label,
2112 )
2113 )
2114
2115 if exact_total:
2116 findings.append(
2117 Finding(
2118 severity="warning",
2119 code="DUPLICATE_EXACT_CANDIDATES",
2120 message=f"Found {exact_total} cross-file exact paragraph groups",
2121 )
2122 )
2123
2124 references, reference_findings = extract_references(root, documents)
2125 findings.extend(reference_findings)
2126 reference_cycles = _strongly_connected_components(
2127 (edge.source, edge.target)
2128 for edge in references
2129 if edge.source.endswith(".md") and edge.target.endswith(".md")
2130 )
2131 authority_candidates = [edge for edge in references if edge.authority_candidate]
2132 authority_candidate_cycles = _strongly_connected_components(
2133 (edge.source, edge.target) for edge in authority_candidates
2134 )
2135 authority_edges, authority_cycles, authority_findings = audit_authority_graph(
2136 root,
2137 manifest.get("authority_edges", []),
2138 )
2139 findings.extend(authority_findings)
2140 reference_pairs = {(edge.source, edge.target) for edge in references}
2141 for edge in authority_edges:
2142 if (edge["from"], edge["to"]) not in reference_pairs:
2143 findings.append(
2144 Finding(
2145 severity="error",
2146 code="AUTHORITY_EDGE_UNREFERENCED",
2147 message=(
2148 f"Declared authority edge has no Markdown reference: "
2149 f"{edge['from']} -> {edge['to']}"
2150 ),
2151 path=manifest_label,
2152 )
2153 )
2154
2155 registries, registry_findings = audit_registries(
2156 root,
2157 registry_configs,
2158 registry_blocks,
2159 documents,
2160 )
2161 findings.extend(registry_findings)
2162 schema_grammars, schema_findings = audit_schema_grammars(
2163 root,
2164 manifest.get("schema_grammars", []),
2165 documents,
2166 manifest_label,
2167 )
2168 findings.extend(schema_findings)
2169
2170 findings.sort(key=_finding_key)
2171 serialized_findings = [asdict(finding) for finding in findings]
2172 errors = sum(finding.severity == "error" for finding in findings)
2173 warnings = sum(finding.severity == "warning" for finding in findings)
2174
2175 return {
2176 "schema_version": 1,
2177 "encoding": encoding_name,
2178 "manifest": {
2179 "audit_only": manifest["audit_only"],
2180 "runtime_consumed": manifest["runtime_consumed"],
2181 "budget_policy": manifest["budget_policy"],
2182 },
2183 "summary": {
2184 "files": len(documents),
2185 "tokens": corpus_tokens,
2186 "max_tokens": corpus_budget,
2187 "errors": errors,
2188 "warnings": warnings,
2189 },
2190 "files": [
2191 {"path": path, "tokens": tokens}
2192 for path, tokens in sorted(token_counts.items(), key=lambda item: (-item[1], item[0]))
2193 ],
2194 "load_sets": load_sets,
2195 "coverage": coverage,
2196 "duplicates": {
2197 "exact_total": exact_total,
2198 "exact": exact,
2199 "exact_accepted": exact_accepted,
2200 "near_scanned": include_near_duplicates,
2201 "near_total": near_total,
2202 "near": near,
2203 "near_accepted": near_accepted,
2204 },
2205 "references": {
2206 "edges": [asdict(edge) for edge in references],
2207 "cycles": reference_cycles,
2208 "authority_candidates": [asdict(edge) for edge in authority_candidates],
2209 "authority_candidate_cycles": authority_candidate_cycles,
2210 "declared_authority_edges": authority_edges,
2211 "declared_authority_cycles": authority_cycles,
2212 },
2213 "registries": registries,
2214 "schema_grammars": schema_grammars,
2215 "findings": serialized_findings,
2216 }
2217
2218
2219 def render_text(report: dict[str, Any]) -> str:
2220 """Render the stable JSON report as a maintainer-oriented text summary."""
2221 summary = report["summary"]
2222 lines = [
2223 "PPT Master Prompt Audit",
2224 "=======================",
2225 "Manifest: audit-only | runtime loading: disabled | budgets: fixed upper bounds",
2226 (
2227 f"Corpus: {summary['files']} files | {summary['tokens']} tokens "
2228 f"(budget {summary['max_tokens']})"
2229 ),
2230 (
2231 f"Coverage: {report['coverage']['covered']} in load sets | "
2232 f"{report['coverage']['exempt']} exempt | "
2233 f"{len(report['coverage']['uncovered'])} uncovered"
2234 ),
2235 f"Findings: {summary['errors']} error(s) | {summary['warnings']} warning(s)",
2236 "",
2237 "Load sets (min / typical / max <= budget):",
2238 ]
2239 for item in report["load_sets"]:
2240 tokens = item["tokens"]
2241 lines.append(
2242 f" {item['status'].upper():4} {item['name']}: "
2243 f"{tokens['min']} / {tokens['typical']} / {tokens['max']} <= {item['max_tokens']}"
2244 )
2245
2246 lines.extend(["", "Registries:"])
2247 for item in report["registries"]:
2248 bounds = ""
2249 if item["minimum_id"] is not None:
2250 bounds = f" | ids {item['minimum_id']}..{item['maximum_id']}"
2251 lines.append(f" {item['name']}: {item['entries']} entries{bounds}")
2252
2253 references = report["references"]
2254 duplicates = report["duplicates"]
2255 schema_open = sum(len(item["open"]) for item in report["schema_grammars"])
2256 schema_accepted = sum(
2257 len(item["accepted"]) for item in report["schema_grammars"]
2258 )
2259 schema_stale = sum(len(item["stale"]) for item in report["schema_grammars"])
2260 near_summary = (
2261 (
2262 f"{duplicates['near_total']} near pair(s) + "
2263 f"{len(duplicates['near_accepted'])} accepted"
2264 )
2265 if duplicates["near_scanned"]
2266 else "near scan skipped"
2267 )
2268 lines.extend(
2269 [
2270 "",
2271 "Candidates:",
2272 (
2273 f" duplicate paragraphs: {duplicates['exact_total']} exact group(s) + "
2274 f"{len(duplicates['exact_accepted'])} accepted, "
2275 f"{near_summary}"
2276 ),
2277 (
2278 f" reference graph: {len(references['edges'])} edge(s), "
2279 f"{len(references['cycles'])} cyclic component(s)"
2280 ),
2281 (
2282 f" authority candidates: {len(references['authority_candidates'])} edge(s), "
2283 f"{len(references['authority_candidate_cycles'])} candidate cycle(s)"
2284 ),
2285 (
2286 f" schema grammar projections: {schema_open} open + "
2287 f"{schema_accepted} accepted + {schema_stale} stale"
2288 ),
2289 ]
2290 )
2291 if duplicates["exact"]:
2292 lines.append(" exact examples:")
2293 for item in duplicates["exact"][:5]:
2294 locations = item["locations"][:2]
2295 lines.append(
2296 " " + " <-> ".join(f"{site['path']}:{site['line']}" for site in locations)
2297 )
2298 if duplicates["near_scanned"] and duplicates["near"]:
2299 lines.append(" near examples:")
2300 for item in duplicates["near"][:5]:
2301 left = item["left"]
2302 right = item["right"]
2303 lines.append(
2304 f" {left['path']}:{left['line']} <-> {right['path']}:{right['line']} "
2305 f"({item['similarity']:.2f})"
2306 )
2307 lines.extend(["", "Per-file tokens:"])
2308 for item in report["files"]:
2309 lines.append(f" {item['tokens']:7d} {item['path']}")
2310
2311 lines.extend(["", "Findings:"])
2312 if not report["findings"]:
2313 lines.append(" none")
2314 else:
2315 for finding in report["findings"]:
2316 location = finding["path"]
2317 if location and finding["line"]:
2318 location += f":{finding['line']}"
2319 prefix = f" {location}" if location else ""
2320 lines.append(
2321 f" [{finding['severity'].upper()} {finding['code']}]{prefix} "
2322 f"{finding['message']}"
2323 )
2324 return "\n".join(lines) + "\n"
2325
2326
2327 def build_parser() -> argparse.ArgumentParser:
2328 parser = argparse.ArgumentParser(
2329 description="Audit PPT Master's prompt budget and governance metadata without writes.",
2330 formatter_class=argparse.RawDescriptionHelpFormatter,
2331 )
2332 default_root = Path(__file__).resolve().parents[3]
2333 default_manifest = Path(__file__).with_name("prompt_audit_manifest.json")
2334 parser.add_argument(
2335 "--root",
2336 type=Path,
2337 default=default_root,
2338 help=f"Repository root (default: {default_root})",
2339 )
2340 parser.add_argument(
2341 "--manifest",
2342 type=Path,
2343 default=default_manifest,
2344 help=f"Audit manifest (default: {default_manifest})",
2345 )
2346 parser.add_argument("--json", action="store_true", help="Emit the complete report as JSON")
2347 parser.add_argument(
2348 "--skip-near-duplicates",
2349 action="store_true",
2350 help="Skip the slower heuristic near-duplicate pass",
2351 )
2352 return parser
2353
2354
2355 def main(argv: list[str] | None = None) -> int:
2356 parser = build_parser()
2357 args = parser.parse_args(argv)
2358 root = args.root.resolve()
2359 manifest_path = args.manifest
2360 if not manifest_path.is_absolute():
2361 manifest_path = root / manifest_path
2362 try:
2363 report = run_audit(
2364 root,
2365 manifest_path,
2366 include_near_duplicates=not args.skip_near_duplicates,
2367 )
2368 except AuditError as exc:
2369 if args.json:
2370 print(
2371 json.dumps(
2372 {
2373 "schema_version": 1,
2374 "error": {
2375 "code": "AUDIT_SETUP_ERROR",
2376 "message": str(exc),
2377 },
2378 },
2379 ensure_ascii=False,
2380 indent=2,
2381 )
2382 )
2383 else:
2384 print(f"Error: {exc}", file=sys.stderr)
2385 return 1
2386
2387 if args.json:
2388 print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=False))
2389 else:
2390 print(render_text(report), end="")
2391 return 1 if report["summary"]["errors"] else 0
2392
2393
2394 if __name__ == "__main__":
2395 raise SystemExit(main())
2396
2396 lines PYTHON