返回 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
59
60 class AuditError(RuntimeError):
61 """Represent a user-actionable audit setup failure."""
62
63
64 @dataclass
65 class Finding:
66 severity: str
67 code: str
68 message: str
69 path: str = ""
70 line: int = 0
71 related: list[str] = field(default_factory=list)
72
73
74 @dataclass
75 class Document:
76 path: str
77 absolute_path: Path
78 text: str
79 tokens: int
80
81
82 @dataclass
83 class Paragraph:
84 path: str
85 line: int
86 text: str
87 normalized: str
88 words: tuple[str, ...]
89
90
91 @dataclass
92 class ReferenceEdge:
93 source: str
94 line: int
95 target: str
96 authority_candidate: bool = False
97
98
99 def _relative_path(root: Path, path: Path) -> str:
100 return path.resolve().relative_to(root.resolve()).as_posix()
101
102
103 def _read_utf8(path: Path) -> str:
104 try:
105 return path.read_text(encoding="utf-8")
106 except (OSError, UnicodeError) as exc:
107 raise AuditError(f"Cannot read UTF-8 file {path}: {exc}") from exc
108
109
110 def _validate_fixed_budget(label: str, budget: Any) -> None:
111 """Require durable rounded ceilings instead of current-count ratchets."""
112 if not isinstance(budget, int) or isinstance(budget, bool) or budget < 1:
113 raise AuditError(f"{label} must be a positive integer")
114 increment = 250 if budget < 10_000 else 1_000 if budget < 100_000 else 5_000
115 if budget % increment:
116 raise AuditError(
117 f"{label} must use a fixed {increment}-token increment, got {budget}"
118 )
119
120
121 def load_manifest(path: Path) -> dict[str, Any]:
122 """Load and validate the prompt-audit manifest."""
123 try:
124 raw = json.loads(_read_utf8(path))
125 except json.JSONDecodeError as exc:
126 raise AuditError(f"Invalid JSON manifest {path}: {exc}") from exc
127
128 if not isinstance(raw, dict) or raw.get("schema_version") != 1:
129 raise AuditError("Manifest must be an object with schema_version: 1")
130 for key in (
131 "audit_only",
132 "runtime_consumed",
133 "budget_policy",
134 "encoding",
135 "documents",
136 "load_sets",
137 ):
138 if key not in raw:
139 raise AuditError(f"Manifest is missing required key: {key}")
140 if raw["audit_only"] is not True or raw["runtime_consumed"] is not False:
141 raise AuditError("Manifest must remain audit-only and excluded from runtime loading")
142 if raw["budget_policy"] != "fixed_upper_bound":
143 raise AuditError("Manifest budget_policy must remain fixed_upper_bound")
144 if raw["encoding"] != "o200k_base":
145 raise AuditError("Manifest encoding must remain o200k_base")
146 documents = raw["documents"]
147 if not isinstance(documents, dict):
148 raise AuditError("documents must be an object")
149 _validate_fixed_budget("documents.max_tokens", documents.get("max_tokens"))
150 for key in ("load_sets", "file_budgets", "duplicates", "coverage"):
151 if key in raw and not isinstance(raw[key], dict):
152 raise AuditError(f"{key} must be an object")
153 for key in ("authority_edges", "registries", "schema_grammars"):
154 if key in raw and not isinstance(raw[key], list):
155 raise AuditError(f"{key} must be an array")
156 for file_path, budget in raw.get("file_budgets", {}).items():
157 _validate_fixed_budget(f"file_budgets.{file_path}", budget)
158 for name, load_set in raw.get("load_sets", {}).items():
159 if not isinstance(load_set, dict):
160 raise AuditError(f"load_sets.{name} must be an object")
161 _validate_fixed_budget(
162 f"load_sets.{name}.max_tokens",
163 load_set.get("max_tokens"),
164 )
165
166 schema_configs = raw.get("schema_grammars", [])
167 for index, config in enumerate(schema_configs):
168 label = f"schema_grammars[{index}]"
169 if not isinstance(config, dict):
170 raise AuditError(f"{label} must be an object")
171 source = config.get("source")
172 if not isinstance(source, str) or not source.strip():
173 raise AuditError(f"{label}.source must be a non-empty path")
174 fields = config.get("fields")
175 if fields is not None and (
176 not isinstance(fields, list)
177 or not fields
178 or not all(isinstance(item, str) and item.strip() for item in fields)
179 or len(set(fields)) != len(fields)
180 ):
181 raise AuditError(
182 f"{label}.fields must be a non-empty array of unique field names"
183 )
184 scan = config.get("scan")
185 if scan is not None and (
186 not isinstance(scan, list)
187 or not scan
188 or not all(isinstance(item, str) and item.strip() for item in scan)
189 ):
190 raise AuditError(
191 f"{label}.scan must be a non-empty array of path patterns"
192 )
193
194 exempt_entries = raw.get("coverage", {}).get("exempt", [])
195 if not isinstance(exempt_entries, list):
196 raise AuditError("coverage.exempt must be an array")
197 for entry in exempt_entries:
198 reason = entry.get("reason") if isinstance(entry, dict) else None
199 if (
200 not isinstance(entry, dict)
201 or not isinstance(entry.get("glob"), str)
202 or not entry["glob"]
203 or not isinstance(reason, str)
204 or not reason.strip()
205 or "\n" in reason
206 or "\r" in reason
207 ):
208 raise AuditError(
209 "coverage.exempt entries require a non-empty glob and one-line reason"
210 )
211
212 accepted_entries = raw.get("duplicates", {}).get("accepted", [])
213 if not isinstance(accepted_entries, list):
214 raise AuditError("duplicates.accepted must be an array")
215 accepted_identities: set[tuple[str, str, tuple[str, ...]]] = set()
216 for entry in accepted_entries:
217 reason = entry.get("reason") if isinstance(entry, dict) else None
218 paths = entry.get("paths") if isinstance(entry, dict) else None
219 if (
220 not isinstance(entry, dict)
221 or entry.get("kind") not in {"exact", "near"}
222 or not isinstance(entry.get("fingerprint"), str)
223 or re.fullmatch(r"[0-9a-f]{12}", entry["fingerprint"]) is None
224 or not isinstance(paths, list)
225 or not paths
226 or not all(isinstance(item, str) and item.strip() for item in paths)
227 or len(set(paths)) != len(paths)
228 or not isinstance(reason, str)
229 or not reason.strip()
230 or "\n" in reason
231 or "\r" in reason
232 ):
233 raise AuditError(
234 "duplicates.accepted entries require kind, 12-hex fingerprint, "
235 "unique paths, and a one-line reason"
236 )
237 identity = (
238 entry["kind"],
239 entry["fingerprint"],
240 tuple(sorted(paths)),
241 )
242 if identity in accepted_identities:
243 raise AuditError(f"Duplicate duplicates.accepted identity: {identity}")
244 accepted_identities.add(identity)
245 return raw
246
247
248 def _expand_globs(
249 root: Path,
250 patterns: Iterable[str],
251 *,
252 require_match: bool,
253 ) -> set[Path]:
254 paths: set[Path] = set()
255 for pattern in patterns:
256 matches = {path for path in root.glob(pattern) if path.is_file()}
257 if require_match and not matches:
258 raise AuditError(f"Document include pattern matched no files: {pattern}")
259 paths.update(matches)
260 return paths
261
262
263 def discover_documents(root: Path, config: dict[str, Any]) -> list[Path]:
264 """Resolve the manifest's document corpus into a stable file list."""
265 include = config.get("include", [])
266 exclude = config.get("exclude", [])
267 if not isinstance(include, list) or not all(isinstance(item, str) for item in include):
268 raise AuditError("documents.include must be a list of path patterns")
269 if not isinstance(exclude, list) or not all(isinstance(item, str) for item in exclude):
270 raise AuditError("documents.exclude must be a list of path patterns")
271
272 paths = _expand_globs(root, include, require_match=True)
273 excluded = _expand_globs(root, exclude, require_match=False) if exclude else set()
274 return sorted(paths - excluded, key=lambda item: _relative_path(root, item))
275
276
277 def _load_encoder(name: str) -> Any:
278 try:
279 import tiktoken
280 except ImportError as exc:
281 raise AuditError(
282 "tiktoken is required for exact prompt counts. "
283 "Install it with: pip install 'tiktoken>=0.7.0'"
284 ) from exc
285
286 try:
287 return tiktoken.get_encoding(name)
288 except (KeyError, ValueError) as exc:
289 raise AuditError(f"tiktoken does not provide the required encoding: {name}") from exc
290
291
292 def count_documents(root: Path, paths: list[Path], encoding_name: str) -> list[Document]:
293 """Read corpus files and count exact tokenizer units."""
294 encoder = _load_encoder(encoding_name)
295 documents: list[Document] = []
296 for path in paths:
297 text = _read_utf8(path)
298 documents.append(
299 Document(
300 path=_relative_path(root, path),
301 absolute_path=path,
302 text=text,
303 tokens=len(encoder.encode(text, disallowed_special=())),
304 )
305 )
306 return documents
307
308
309 def _matches_any(path: str, patterns: Iterable[str]) -> bool:
310 candidate = PurePosixPath(path)
311 return any(candidate.match(pattern) for pattern in patterns)
312
313
314 def _load_entry_paths(root: Path, entry: Any) -> tuple[list[Path], int | None, str]:
315 if isinstance(entry, str):
316 path = root / entry
317 if not path.is_file():
318 raise AuditError(f"Load-set file does not exist: {entry}")
319 return [path], None, entry
320
321 if not isinstance(entry, dict) or not isinstance(entry.get("glob"), str):
322 raise AuditError("Each load-set file entry must be a path or an object with glob")
323
324 pattern = entry["glob"]
325 paths = sorted(root.glob(pattern))
326 paths = [path for path in paths if path.is_file()]
327 excludes = entry.get("exclude", [])
328 if excludes:
329 paths = [
330 path
331 for path in paths
332 if not _matches_any(_relative_path(root, path), excludes)
333 ]
334 if not paths:
335 raise AuditError(f"Load-set selector matched no files: {pattern}")
336
337 select = entry.get("select")
338 if not isinstance(select, int) or select < 1 or select > len(paths):
339 raise AuditError(
340 f"Load-set selector {pattern} has invalid select={select}; "
341 f"expected 1..{len(paths)}"
342 )
343 return paths, select, pattern
344
345
346 def audit_load_sets(
347 root: Path,
348 config: dict[str, Any],
349 token_counts: dict[str, int],
350 manifest_label: str,
351 registry_members: dict[str, set[Any]],
352 ) -> tuple[list[dict[str, Any]], list[Finding], set[str]]:
353 """Resolve declared load scenarios and enforce their maximum budgets."""
354 findings: list[Finding] = []
355 results: list[dict[str, Any]] = []
356 resolved_cache: dict[str, list[Any]] = {}
357 covered_paths: set[str] = set()
358
359 def entry_key(entry: Any) -> str:
360 if isinstance(entry, dict):
361 return json.dumps(entry, sort_keys=True)
362 return f"path:{entry}"
363
364 def resolve_entries(name: str, stack: tuple[str, ...] = ()) -> list[Any]:
365 if name in resolved_cache:
366 return resolved_cache[name]
367 if name not in config:
368 raise AuditError(f"Load set includes unknown set: {name}")
369 if name in stack:
370 cycle = " -> ".join((*stack, name))
371 raise AuditError(f"Load-set include cycle: {cycle}")
372 load_set = config[name]
373 if not isinstance(load_set, dict):
374 raise AuditError(f"load_sets.{name} must be an object")
375 includes = load_set.get("include", [])
376 entries = load_set.get("files", [])
377 if not isinstance(includes, list) or not all(isinstance(item, str) for item in includes):
378 raise AuditError(f"load_sets.{name}.include must be a list of set names")
379 if not isinstance(entries, list):
380 raise AuditError(f"load_sets.{name}.files must be a list")
381
382 resolved: list[Any] = []
383 seen: set[str] = set()
384 for included in includes:
385 for entry in resolve_entries(included, (*stack, name)):
386 key = entry_key(entry)
387 if key not in seen:
388 seen.add(key)
389 resolved.append(entry)
390 for entry in entries:
391 key = entry_key(entry)
392 if key not in seen:
393 seen.add(key)
394 resolved.append(entry)
395 resolved_cache[name] = resolved
396 return resolved
397
398 for name, load_set in sorted(config.items()):
399 if not isinstance(load_set, dict):
400 raise AuditError(f"load_sets.{name} must be an object")
401 entries = resolve_entries(name)
402 budget = load_set.get("max_tokens")
403 if not isinstance(budget, int) or budget < 1:
404 raise AuditError(f"load_sets.{name} requires a positive max_tokens")
405
406 fixed: set[str] = set()
407 selectors: list[dict[str, Any]] = []
408 claimed_options: set[str] = set()
409 for entry in entries:
410 paths, select, label = _load_entry_paths(root, entry)
411 relative = [_relative_path(root, path) for path in paths]
412 covered_paths.update(relative)
413 missing_counts = [path for path in relative if path not in token_counts]
414 if missing_counts:
415 raise AuditError(
416 f"Load set {name} references files outside documents.include: "
417 + ", ".join(missing_counts)
418 )
419
420 if select is None:
421 overlap = (fixed | claimed_options).intersection(relative)
422 if overlap:
423 raise AuditError(
424 f"Load set {name} repeats fixed files: {', '.join(sorted(overlap))}"
425 )
426 fixed.update(relative)
427 continue
428
429 registry_name = entry.get("registry") if isinstance(entry, dict) else None
430 if registry_name is not None:
431 if registry_name not in registry_members:
432 raise AuditError(
433 f"Load set {name} selector references unknown registry: {registry_name}"
434 )
435 candidate_ids = {Path(path).stem for path in relative}
436 expected_ids = {str(item) for item in registry_members[registry_name]}
437 if candidate_ids != expected_ids:
438 missing = sorted(expected_ids - candidate_ids)
439 extra = sorted(candidate_ids - expected_ids)
440 raise AuditError(
441 f"Load set {name} selector does not match registry {registry_name}; "
442 f"missing={missing}, extra={extra}"
443 )
444
445 overlap = claimed_options.intersection(relative) | fixed.intersection(relative)
446 allow_repeat = isinstance(entry, dict) and entry.get("allow_repeat") is True
447 load_event = str(entry.get("load_event", ""))
448 if allow_repeat and not load_event:
449 raise AuditError(
450 f"Load set {name} uses allow_repeat without a named load_event: {label}"
451 )
452 if overlap and not allow_repeat:
453 raise AuditError(
454 f"Load set {name} has overlapping selector files: "
455 + ", ".join(sorted(overlap))
456 )
457 claimed_options.update(relative)
458 counts = sorted(token_counts[path] for path in relative)
459 selectors.append(
460 {
461 "glob": label,
462 "load_event": load_event,
463 "registry": str(registry_name or ""),
464 "select": select,
465 "candidates": len(relative),
466 "min_tokens": sum(counts[:select]),
467 "typical_tokens": round(statistics.mean(counts) * select),
468 "max_tokens": sum(counts[-select:]),
469 }
470 )
471
472 fixed_tokens = sum(token_counts[path] for path in fixed)
473 minimum = fixed_tokens + sum(item["min_tokens"] for item in selectors)
474 typical = fixed_tokens + sum(item["typical_tokens"] for item in selectors)
475 maximum = fixed_tokens + sum(item["max_tokens"] for item in selectors)
476 status = "pass" if maximum <= budget else "fail"
477 if status == "fail":
478 findings.append(
479 Finding(
480 severity="error",
481 code="BUDGET_LOAD_SET",
482 message=f"{name} maximum {maximum} exceeds budget {budget}",
483 path=manifest_label,
484 )
485 )
486 results.append(
487 {
488 "name": name,
489 "description": str(load_set.get("description", "")),
490 "scope": str(load_set.get("scope", "incremental")),
491 "includes": list(load_set.get("include", [])),
492 "fixed_files": sorted(fixed),
493 "selectors": selectors,
494 "tokens": {"min": minimum, "typical": typical, "max": maximum},
495 "max_tokens": budget,
496 "status": status,
497 }
498 )
499 return results, findings, covered_paths
500
501
502 def audit_load_coverage(
503 document_paths: Iterable[str],
504 covered_paths: set[str],
505 config: dict[str, Any],
506 manifest_label: str,
507 ) -> tuple[dict[str, Any], list[Finding]]:
508 """Force every corpus document into a load set or an explicit exemption."""
509 findings: list[Finding] = []
510 exempt_entries = config.get("exempt", [])
511 all_paths = sorted(document_paths)
512 exempt_paths: set[str] = set()
513 exempt_owner: dict[str, str] = {}
514
515 for entry in exempt_entries:
516 glob = entry["glob"]
517 matches = {
518 path for path in all_paths if PurePosixPath(path).match(glob)
519 }
520 if not matches:
521 findings.append(
522 Finding(
523 severity="error",
524 code="COVERAGE_EXEMPT_STALE",
525 message=f"coverage.exempt glob matches no corpus file: {glob}",
526 path=manifest_label,
527 )
528 )
529 continue
530 duplicate_exemptions = sorted(path for path in matches if path in exempt_owner)
531 if duplicate_exemptions:
532 details = ", ".join(
533 f"{path} (already matched by {exempt_owner[path]})"
534 for path in duplicate_exemptions
535 )
536 findings.append(
537 Finding(
538 severity="error",
539 code="COVERAGE_EXEMPT_DUPLICATE",
540 message=f"coverage.exempt glob {glob} overlaps: {details}",
541 path=manifest_label,
542 )
543 )
544 overlap = sorted(matches & covered_paths)
545 if overlap:
546 findings.append(
547 Finding(
548 severity="error",
549 code="COVERAGE_EXEMPT_OVERLAP",
550 message=(
551 f"coverage.exempt glob {glob} matches load-set files: "
552 + ", ".join(overlap)
553 ),
554 path=manifest_label,
555 )
556 )
557 for path in matches:
558 exempt_owner.setdefault(path, glob)
559 exempt_paths.update(matches)
560
561 uncovered = sorted(set(all_paths) - covered_paths - exempt_paths)
562 for path in uncovered:
563 findings.append(
564 Finding(
565 severity="error",
566 code="LOAD_COVERAGE_GAP",
567 message=(
568 "Document is in no load set and has no coverage.exempt entry; "
569 "add it to a load set or exempt it with a reason"
570 ),
571 path=path,
572 )
573 )
574 return (
575 {
576 "documents": len(all_paths),
577 "covered": len(set(all_paths) & covered_paths),
578 "exempt": len(exempt_paths - covered_paths),
579 "uncovered": uncovered,
580 },
581 findings,
582 )
583
584
585 def audit_file_budgets(
586 budgets: dict[str, Any],
587 token_counts: dict[str, int],
588 ) -> list[Finding]:
589 """Check explicit per-file growth ceilings."""
590 findings: list[Finding] = []
591 for path, budget in sorted(budgets.items()):
592 if path not in token_counts:
593 raise AuditError(f"File budget references a file outside the corpus: {path}")
594 if not isinstance(budget, int) or budget < 1:
595 raise AuditError(f"File budget for {path} must be a positive integer")
596 actual = token_counts[path]
597 if actual > budget:
598 findings.append(
599 Finding(
600 severity="error",
601 code="BUDGET_FILE",
602 message=f"File has {actual} tokens; budget is {budget}",
603 path=path,
604 )
605 )
606 return findings
607
608
609 def _duplicate_fingerprint(*texts: str) -> str:
610 joined = "\n\x00\n".join(sorted(texts))
611 return hashlib.sha1(joined.encode("utf-8")).hexdigest()[:12]
612
613
614 def _accepted_identity(
615 kind: str,
616 fingerprint: str,
617 paths: Iterable[str],
618 ) -> tuple[str, str, tuple[str, ...]]:
619 return kind, fingerprint, tuple(sorted(paths))
620
621
622 def _partition_accepted(
623 entries: list[dict[str, Any]],
624 kind: str,
625 accepted: list[dict[str, Any]],
626 used: set[tuple[str, str, tuple[str, ...]]],
627 ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
628 """Split duplicate findings into open ones and manifest-accepted ones."""
629 lookup = {
630 _accepted_identity(item["kind"], item["fingerprint"], item["paths"]): item
631 for item in accepted
632 if item["kind"] == kind
633 }
634 open_entries: list[dict[str, Any]] = []
635 accepted_entries: list[dict[str, Any]] = []
636 for entry in entries:
637 identity = _accepted_identity(kind, entry["fingerprint"], entry["paths"])
638 match = lookup.get(identity)
639 if match is not None:
640 used.add(identity)
641 accepted_entries.append({**entry, "reason": match["reason"]})
642 else:
643 open_entries.append(entry)
644 return open_entries, accepted_entries
645
646
647 def _normalize_paragraph(text: str) -> str:
648 text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
649 text = re.sub(r"[`*_>#|]", " ", text)
650 text = re.sub(r"\s+", " ", text).strip().lower()
651 return text
652
653
654 def extract_paragraphs(
655 documents: list[Document],
656 config: dict[str, Any],
657 ) -> list[Paragraph]:
658 """Extract prose blocks for duplicate and registry analysis."""
659 minimum = int(config.get("min_chars", 100))
660 paragraphs: list[Paragraph] = []
661
662 for document in documents:
663 if not document.path.endswith(".md"):
664 continue
665 block: list[str] = []
666 block_line = 1
667 in_fence = False
668
669 def flush() -> None:
670 nonlocal block
671 text = "\n".join(block).strip()
672 block = []
673 if len(text) < minimum:
674 return
675 normalized = _normalize_paragraph(text)
676 words = tuple(_WORD_RE.findall(normalized))
677 if not normalized or not words:
678 return
679 paragraphs.append(
680 Paragraph(
681 path=document.path,
682 line=block_line,
683 text=text,
684 normalized=normalized,
685 words=words,
686 )
687 )
688
689 for line_number, line in enumerate(document.text.splitlines(), start=1):
690 if _FENCE_RE.match(line):
691 flush()
692 in_fence = not in_fence
693 continue
694 if in_fence:
695 continue
696 if not line.strip() or _HEADING_RE.match(line) or line.strip() == "---":
697 flush()
698 continue
699 if not block:
700 block_line = line_number
701 block.append(line)
702 flush()
703
704 return paragraphs
705
706
707 def extract_registry_blocks(documents: list[Document]) -> list[Paragraph]:
708 """Extract unfiltered Markdown blocks, including headings and fenced examples."""
709 blocks: list[Paragraph] = []
710 for document in documents:
711 if not document.path.endswith(".md"):
712 continue
713 lines: list[str] = []
714 block_line = 1
715
716 def flush() -> None:
717 nonlocal lines
718 text = "\n".join(lines).strip()
719 lines = []
720 if not text:
721 return
722 normalized = _normalize_paragraph(text)
723 blocks.append(
724 Paragraph(
725 path=document.path,
726 line=block_line,
727 text=text,
728 normalized=normalized,
729 words=tuple(_WORD_RE.findall(normalized)),
730 )
731 )
732
733 for line_number, line in enumerate(document.text.splitlines(), start=1):
734 if not line.strip():
735 flush()
736 continue
737 if not lines:
738 block_line = line_number
739 lines.append(line)
740 flush()
741 return blocks
742
743
744 def find_exact_duplicates(
745 paragraphs: list[Paragraph],
746 ) -> tuple[list[dict[str, Any]], int]:
747 """Find normalized prose blocks copied across files."""
748 groups: dict[str, list[Paragraph]] = defaultdict(list)
749 for paragraph in paragraphs:
750 groups[paragraph.normalized].append(paragraph)
751
752 duplicates: list[dict[str, Any]] = []
753 for normalized, items in groups.items():
754 if len({item.path for item in items}) < 2:
755 continue
756 paths = sorted({item.path for item in items})
757 locations = sorted(
758 ({"path": item.path, "line": item.line} for item in items),
759 key=lambda item: (item["path"], item["line"]),
760 )
761 duplicates.append(
762 {
763 "kind": "exact",
764 "fingerprint": _duplicate_fingerprint(*(item.text for item in items)),
765 "paths": paths,
766 "chars": len(normalized),
767 "preview": normalized[:180],
768 "locations": locations,
769 }
770 )
771 duplicates.sort(key=lambda item: (-item["chars"], item["locations"][0]["path"]))
772 return duplicates, len(duplicates)
773
774
775 def find_near_duplicates(
776 paragraphs: list[Paragraph],
777 config: dict[str, Any],
778 ) -> tuple[list[dict[str, Any]], int]:
779 """Find heuristic near-duplicate paragraph candidates using word shingles."""
780 shingle_size = int(config.get("shingle_words", 4))
781 minimum_words = int(config.get("min_words", 20))
782 minimum_hits = int(config.get("min_shared_shingles", 3))
783 max_frequency = int(config.get("max_shingle_frequency", 24))
784 threshold = float(config.get("near_similarity", 0.82))
785
786 eligible = [item for item in paragraphs if len(item.words) >= minimum_words]
787 shingle_sets: list[set[tuple[str, ...]]] = []
788 inverted: dict[tuple[str, ...], list[int]] = defaultdict(list)
789 for index, paragraph in enumerate(eligible):
790 shingles = {
791 paragraph.words[offset : offset + shingle_size]
792 for offset in range(len(paragraph.words) - shingle_size + 1)
793 }
794 shingle_sets.append(shingles)
795 for shingle in shingles:
796 inverted[shingle].append(index)
797
798 hits: Counter[tuple[int, int]] = Counter()
799 for indices in inverted.values():
800 if len(indices) > max_frequency:
801 continue
802 for left_offset, left in enumerate(indices):
803 for right in indices[left_offset + 1 :]:
804 if eligible[left].path != eligible[right].path:
805 hits[(left, right)] += 1
806
807 candidates: list[dict[str, Any]] = []
808 for (left, right), shared in hits.items():
809 if shared < minimum_hits:
810 continue
811 first = eligible[left]
812 second = eligible[right]
813 if first.normalized == second.normalized:
814 continue
815 union = shingle_sets[left] | shingle_sets[right]
816 if not union or shared / len(union) < 0.18:
817 continue
818 similarity = SequenceMatcher(
819 None,
820 first.normalized,
821 second.normalized,
822 autojunk=False,
823 ).ratio()
824 if similarity < threshold:
825 continue
826 candidates.append(
827 {
828 "kind": "near",
829 "fingerprint": _duplicate_fingerprint(first.text, second.text),
830 "paths": sorted((first.path, second.path)),
831 "similarity": round(similarity, 4),
832 "left": {"path": first.path, "line": first.line},
833 "right": {"path": second.path, "line": second.line},
834 "preview": first.normalized[:180],
835 }
836 )
837
838 candidates.sort(
839 key=lambda item: (
840 -item["similarity"],
841 item["left"]["path"],
842 item["left"]["line"],
843 item["right"]["path"],
844 item["right"]["line"],
845 )
846 )
847 return candidates, len(candidates)
848
849
850 def _clean_link_target(raw: str) -> str:
851 target = raw.strip()
852 if target.startswith("<") and ">" in target:
853 target = target[1 : target.index(">")]
854 elif " " in target:
855 target = target.split(" ", 1)[0]
856 return unquote(target.strip())
857
858
859 def extract_references(
860 root: Path,
861 documents: list[Document],
862 ) -> tuple[list[ReferenceEdge], list[Finding]]:
863 """Extract local Markdown links and report missing targets."""
864 edges: list[ReferenceEdge] = []
865 findings: list[Finding] = []
866
867 for document in documents:
868 if not document.path.endswith(".md"):
869 continue
870 in_fence = False
871 for line_number, line in enumerate(document.text.splitlines(), start=1):
872 if _FENCE_RE.match(line):
873 in_fence = not in_fence
874 continue
875 if in_fence:
876 continue
877 for match in _MARKDOWN_LINK_RE.finditer(line):
878 raw_target = _clean_link_target(match.group(1))
879 if (
880 not raw_target
881 or raw_target.startswith("#")
882 or raw_target.startswith(_EXTERNAL_SCHEMES)
883 or "${" in raw_target
884 or "<" in raw_target
885 ):
886 continue
887 target_without_anchor = raw_target.split("#", 1)[0].split("?", 1)[0]
888 if not target_without_anchor:
889 continue
890 target_path = (document.absolute_path.parent / target_without_anchor).resolve()
891 try:
892 target_relative = _relative_path(root, target_path)
893 except ValueError:
894 findings.append(
895 Finding(
896 severity="error",
897 code="REFERENCE_OUTSIDE_ROOT",
898 message=f"Local link escapes repository root: {raw_target}",
899 path=document.path,
900 line=line_number,
901 )
902 )
903 continue
904 if not target_path.exists():
905 findings.append(
906 Finding(
907 severity="error",
908 code="REFERENCE_MISSING",
909 message=f"Markdown target does not exist: {raw_target}",
910 path=document.path,
911 line=line_number,
912 )
913 )
914 continue
915 edges.append(
916 ReferenceEdge(
917 source=document.path,
918 line=line_number,
919 target=target_relative,
920 authority_candidate=bool(_AUTHORITY_TERMS_RE.search(line)),
921 )
922 )
923 edges.sort(key=lambda edge: (edge.source, edge.line, edge.target))
924 return edges, findings
925
926
927 def _strongly_connected_components(edges: Iterable[tuple[str, str]]) -> list[list[str]]:
928 graph: dict[str, set[str]] = defaultdict(set)
929 nodes: set[str] = set()
930 for source, target in edges:
931 graph[source].add(target)
932 nodes.update((source, target))
933
934 index = 0
935 stack: list[str] = []
936 indices: dict[str, int] = {}
937 low_links: dict[str, int] = {}
938 on_stack: set[str] = set()
939 components: list[list[str]] = []
940
941 def visit(node: str) -> None:
942 nonlocal index
943 indices[node] = index
944 low_links[node] = index
945 index += 1
946 stack.append(node)
947 on_stack.add(node)
948
949 for neighbor in sorted(graph.get(node, set())):
950 if neighbor not in indices:
951 visit(neighbor)
952 low_links[node] = min(low_links[node], low_links[neighbor])
953 elif neighbor in on_stack:
954 low_links[node] = min(low_links[node], indices[neighbor])
955
956 if low_links[node] != indices[node]:
957 return
958 component: list[str] = []
959 while stack:
960 member = stack.pop()
961 on_stack.remove(member)
962 component.append(member)
963 if member == node:
964 break
965 if len(component) > 1 or node in graph.get(node, set()):
966 components.append(sorted(component))
967
968 for node in sorted(nodes):
969 if node not in indices:
970 visit(node)
971 return sorted(components, key=lambda item: (len(item), item))
972
973
974 def audit_authority_graph(
975 root: Path,
976 edges_config: list[dict[str, Any]],
977 ) -> tuple[list[dict[str, str]], list[list[str]], list[Finding]]:
978 """Validate the explicit concern-level authority DAG."""
979 findings: list[Finding] = []
980 normalized: list[dict[str, str]] = []
981 by_concern: dict[str, list[tuple[str, str]]] = defaultdict(list)
982
983 for edge in edges_config:
984 if not isinstance(edge, dict):
985 raise AuditError("authority_edges entries must be objects")
986 source = edge.get("from")
987 target = edge.get("to")
988 concern = edge.get("concern")
989 if not all(isinstance(value, str) and value for value in (source, target, concern)):
990 raise AuditError("authority_edges require non-empty from, to, and concern")
991 for path in (source, target):
992 if not (root / path).is_file():
993 raise AuditError(f"Authority edge references missing file: {path}")
994 normalized.append({"from": source, "to": target, "concern": concern})
995 by_concern[concern].append((source, target))
996
997 cycles: list[list[str]] = []
998 for concern, concern_edges in sorted(by_concern.items()):
999 for component in _strongly_connected_components(concern_edges):
1000 labeled = [f"{concern}:{path}" for path in component]
1001 cycles.append(labeled)
1002 findings.append(
1003 Finding(
1004 severity="error",
1005 code="AUTHORITY_CYCLE",
1006 message=f"Authority cycle for concern {concern}: " + " -> ".join(component),
1007 )
1008 )
1009 normalized.sort(key=lambda item: (item["concern"], item["from"], item["to"]))
1010 return normalized, cycles, findings
1011
1012
1013 def _structured_registry_expected_order(config: dict[str, Any]) -> list[str]:
1014 """Build the canonical browse order for one structured registry."""
1015 prefix_counts = config.get("prefix_counts")
1016 sequence_width = config.get("sequence_width")
1017 if (
1018 not isinstance(prefix_counts, dict)
1019 or not prefix_counts
1020 or not all(
1021 isinstance(prefix, str)
1022 and re.fullmatch(r"[PMAC][1-9]\d*", prefix)
1023 and isinstance(count, int)
1024 and count > 0
1025 for prefix, count in prefix_counts.items()
1026 )
1027 ):
1028 raise AuditError(
1029 f"Registry {config.get('name')} needs positive prefix_counts"
1030 )
1031 if not isinstance(sequence_width, int) or sequence_width < 1:
1032 raise AuditError(
1033 f"Registry {config.get('name')} needs a positive sequence_width"
1034 )
1035 return [
1036 f"{prefix}-{sequence:0{sequence_width}d}"
1037 for prefix, count in prefix_counts.items()
1038 for sequence in range(1, count + 1)
1039 ]
1040
1041
1042 def _registry_ids(
1043 root: Path,
1044 config: dict[str, Any],
1045 ) -> tuple[set[Any], str, list[Any], list[Any] | None]:
1046 kind = config.get("kind")
1047 source = config.get("source")
1048 if not isinstance(source, str) or not (root / source).is_file():
1049 raise AuditError(f"Registry has missing source: {source}")
1050
1051 if kind == "structured_markdown":
1052 pattern = config.get("entry_pattern")
1053 if not isinstance(pattern, str):
1054 raise AuditError(f"Registry {config.get('name')} needs entry_pattern")
1055 try:
1056 regex = re.compile(pattern, re.MULTILINE)
1057 except re.error as exc:
1058 raise AuditError(f"Invalid registry entry_pattern: {exc}") from exc
1059 if "id" not in regex.groupindex:
1060 raise AuditError(
1061 f"Registry {config.get('name')} entry_pattern needs an id group"
1062 )
1063 matched_ids = [
1064 match.group("id")
1065 for match in regex.finditer(_read_utf8(root / source))
1066 ]
1067 duplicates = sorted(
1068 item for item, count in Counter(matched_ids).items() if count > 1
1069 )
1070 return set(matched_ids), source, duplicates, matched_ids
1071
1072 if kind == "directory":
1073 pattern = config.get("glob")
1074 excludes = config.get("exclude", [])
1075 if not isinstance(pattern, str):
1076 raise AuditError(f"Registry {config.get('name')} needs glob")
1077 paths = [path for path in root.glob(pattern) if path.is_file()]
1078 ids = {
1079 path.stem
1080 for path in paths
1081 if not _matches_any(_relative_path(root, path), excludes)
1082 }
1083 return ids, source, [], None
1084
1085 if kind == "json_collection":
1086 key = config.get("key")
1087 if not isinstance(key, str):
1088 raise AuditError(f"Registry {config.get('name')} needs key")
1089 try:
1090 value: Any = json.loads(_read_utf8(root / source))
1091 except json.JSONDecodeError as exc:
1092 raise AuditError(f"Registry source is invalid JSON: {source}: {exc}") from exc
1093 for part in key.split("."):
1094 if not isinstance(value, dict) or part not in value:
1095 raise AuditError(f"Registry key {key} is missing in {source}")
1096 value = value[part]
1097 if isinstance(value, dict):
1098 return set(value), source, [], None
1099 if isinstance(value, list):
1100 return set(range(len(value))), source, [], None
1101 raise AuditError(f"Registry key {key} in {source} is not a collection")
1102
1103 raise AuditError(f"Unsupported registry kind: {kind}")
1104
1105
1106 def _registry_count_claims(paragraph: Paragraph, nouns: list[str]) -> list[int]:
1107 noun_pattern = "|".join(re.escape(noun) for noun in nouns)
1108 patterns = (
1109 rf"\bcatalog\s*\(\s*(\d+)\s+(?:{noun_pattern})\s*\)",
1110 rf"\bcatalog\s+read\s*:?\s*(\d+)\s+(?:{noun_pattern})\b",
1111 rf"\(\s*(\d+)\s+(?:{noun_pattern})\s*\)",
1112 rf"\b(?:all|every|the|total(?:\s+of)?|contains?|currently(?:\s+has)?|read(?:\s+all)?)\s+"
1113 rf"(\d+)\s+(?:{noun_pattern})\b",
1114 )
1115 claims: list[int] = []
1116 for pattern in patterns:
1117 matches = re.finditer(pattern, paragraph.normalized, re.I)
1118 claims.extend(int(match.group(1)) for match in matches)
1119 return claims
1120
1121
1122 def audit_registries(
1123 root: Path,
1124 configs: list[dict[str, Any]],
1125 paragraphs: list[Paragraph],
1126 documents: list[Document],
1127 ) -> tuple[list[dict[str, Any]], list[Finding]]:
1128 """Compare live registry membership with documentation claims."""
1129 findings: list[Finding] = []
1130 reports: list[dict[str, Any]] = []
1131
1132 for config in configs:
1133 name = config.get("name")
1134 if not isinstance(name, str) or not name:
1135 raise AuditError("Every registry requires a name")
1136 ids, source, duplicate_ids, source_order = _registry_ids(root, config)
1137 if not ids:
1138 raise AuditError(f"Registry {name} has no entries")
1139 for duplicate_id in duplicate_ids:
1140 findings.append(
1141 Finding(
1142 severity="error",
1143 code="REGISTRY_ID_DUPLICATE",
1144 message=f"{name} defines id {duplicate_id!r} more than once",
1145 path=source,
1146 )
1147 )
1148 expected_order: list[str] | None = None
1149 expected_ids: set[str] | None = None
1150 if config.get("kind") == "structured_markdown":
1151 expected_order = _structured_registry_expected_order(config)
1152 expected_ids = set(expected_order)
1153 string_ids = {str(item) for item in ids}
1154 missing_ids = sorted(expected_ids - string_ids)
1155 extra_ids = sorted(string_ids - expected_ids)
1156 if missing_ids:
1157 findings.append(
1158 Finding(
1159 severity="error",
1160 code="REGISTRY_IDS_MISSING",
1161 message=(
1162 f"{name} is missing canonical ids: "
1163 + ", ".join(missing_ids)
1164 ),
1165 path=source,
1166 )
1167 )
1168 if (
1169 not missing_ids
1170 and not extra_ids
1171 and not duplicate_ids
1172 and source_order != expected_order
1173 ):
1174 mismatch = next(
1175 index
1176 for index, (actual, expected) in enumerate(
1177 zip(source_order or [], expected_order)
1178 )
1179 if actual != expected
1180 )
1181 findings.append(
1182 Finding(
1183 severity="error",
1184 code="REGISTRY_ID_ORDER",
1185 message=(
1186 f"{name} browse order expects {expected_order[mismatch]} "
1187 f"at position {mismatch + 1}; found {source_order[mismatch]}"
1188 ),
1189 path=source,
1190 )
1191 )
1192 if extra_ids:
1193 findings.append(
1194 Finding(
1195 severity="error",
1196 code="REGISTRY_IDS_EXTRA",
1197 message=(
1198 f"{name} defines unexpected canonical ids: "
1199 + ", ".join(extra_ids)
1200 ),
1201 path=source,
1202 )
1203 )
1204 index_labels: list[str] | None = None
1205 index_targets: list[str] | None = None
1206 if config.get("validate_index_links") is True:
1207 if config.get("kind") != "directory":
1208 raise AuditError(
1209 f"Registry {name} enables validate_index_links but is not a directory"
1210 )
1211 index_matches = list(
1212 _REGISTRY_INDEX_ENTRY_RE.finditer(_read_utf8(root / source))
1213 )
1214 index_labels = [match.group("label") for match in index_matches]
1215 index_targets = [match.group("target") for match in index_matches]
1216 for label, target in zip(index_labels, index_targets):
1217 if label != target:
1218 findings.append(
1219 Finding(
1220 severity="error",
1221 code="REGISTRY_INDEX_LABEL_TARGET_MISMATCH",
1222 message=(
1223 f"{name} index label {label!r} points to {target!r}"
1224 ),
1225 path=source,
1226 )
1227 )
1228 for field_name, values in (
1229 ("label", index_labels),
1230 ("target", index_targets),
1231 ):
1232 duplicates = sorted(
1233 value for value, count in Counter(values).items() if count > 1
1234 )
1235 if duplicates:
1236 findings.append(
1237 Finding(
1238 severity="error",
1239 code="REGISTRY_INDEX_DUPLICATE",
1240 message=(
1241 f"{name} index repeats {field_name}(s): "
1242 + ", ".join(duplicates)
1243 ),
1244 path=source,
1245 )
1246 )
1247 index_ids = set(values)
1248 if index_ids != ids:
1249 missing = sorted(str(item) for item in ids - index_ids)
1250 extra = sorted(str(item) for item in index_ids - ids)
1251 findings.append(
1252 Finding(
1253 severity="error",
1254 code="REGISTRY_INDEX_MISMATCH",
1255 message=(
1256 f"{name} index {field_name}s differ from registry files; "
1257 f"missing={missing}, extra={extra}"
1258 ),
1259 path=source,
1260 )
1261 )
1262 terms = [_normalize_paragraph(str(item)) for item in config.get("reference_terms", [])]
1263 source_name = Path(source).name
1264 if not source_name.startswith("_"):
1265 terms.append(_normalize_paragraph(source_name))
1266 nouns = [str(item).lower() for item in config.get("claim_nouns", [])]
1267 claims: list[dict[str, Any]] = []
1268 seen_claims: set[tuple[str, int, int]] = set()
1269 line_claim_keys: set[tuple[str, int]] = set()
1270
1271 def record_count_claim(path: str, line: int, count: int) -> None:
1272 key = (path, line, count)
1273 if key in seen_claims:
1274 return
1275 seen_claims.add(key)
1276 claims.append({"path": path, "line": line, "count": count})
1277 if count != len(ids):
1278 findings.append(
1279 Finding(
1280 severity="error",
1281 code="REGISTRY_COUNT_MISMATCH",
1282 message=f"{name} claims {count} entries; registry contains {len(ids)}",
1283 path=path,
1284 line=line,
1285 )
1286 )
1287
1288 for document in documents:
1289 if not document.path.endswith(".md"):
1290 continue
1291 for line_number, line in enumerate(document.text.splitlines(), start=1):
1292 normalized = _normalize_paragraph(line)
1293 if document.path != source and not any(term in normalized for term in terms):
1294 continue
1295 claim_line = Paragraph(
1296 path=document.path,
1297 line=line_number,
1298 text=line,
1299 normalized=normalized,
1300 words=tuple(_WORD_RE.findall(normalized)),
1301 )
1302 for count in _registry_count_claims(claim_line, nouns):
1303 line_claim_keys.add((document.path, count))
1304 record_count_claim(document.path, line_number, count)
1305
1306 for paragraph in paragraphs:
1307 registry_context = paragraph.path == source or any(
1308 term in paragraph.normalized for term in terms
1309 )
1310 if registry_context:
1311 for count in _registry_count_claims(paragraph, nouns):
1312 if (paragraph.path, count) not in line_claim_keys:
1313 record_count_claim(paragraph.path, paragraph.line, count)
1314 kind = config.get("kind")
1315 if kind == "structured_markdown":
1316 canonical_ids = {str(item) for item in ids}
1317 structured_pattern = re.compile(r"#([PMAC][1-9]\d*-\d+)\b")
1318 for match in structured_pattern.finditer(paragraph.text):
1319 claimed_id = match.group(1)
1320 if claimed_id not in canonical_ids:
1321 findings.append(
1322 Finding(
1323 severity="error",
1324 code="REGISTRY_ID_MISSING",
1325 message=(
1326 f"{name} references missing id #{claimed_id}"
1327 ),
1328 path=paragraph.path,
1329 line=paragraph.line,
1330 )
1331 )
1332 legacy_named_pattern = re.compile(
1333 r"#(?P<id>"
1334 r"(?:single|canvas|multi|reveal|tone|depth|asset|continuity)_\d+"
1335 r")\b"
1336 )
1337 for match in legacy_named_pattern.finditer(paragraph.text):
1338 findings.append(
1339 Finding(
1340 severity="error",
1341 code="REGISTRY_ID_LEGACY",
1342 message=(
1343 f"{name} uses removed id #{match.group('id')}"
1344 ),
1345 path=paragraph.path,
1346 line=paragraph.line,
1347 )
1348 )
1349 if registry_context:
1350 legacy_numeric_pattern = re.compile(r"#(?P<id>[1-9]\d*)\b")
1351 for match in legacy_numeric_pattern.finditer(paragraph.text):
1352 findings.append(
1353 Finding(
1354 severity="error",
1355 code="REGISTRY_ID_LEGACY",
1356 message=(
1357 f"{name} uses removed id #{match.group('id')}"
1358 ),
1359 path=paragraph.path,
1360 line=paragraph.line,
1361 )
1362 )
1363 continue
1364
1365 reports.append(
1366 {
1367 "name": name,
1368 "source": source,
1369 "entries": len(ids),
1370 "duplicate_ids": duplicate_ids,
1371 "minimum_id": min(ids) if all(isinstance(item, int) for item in ids) else None,
1372 "maximum_id": max(ids) if all(isinstance(item, int) for item in ids) else None,
1373 "expected_entries": len(expected_ids) if expected_ids is not None else None,
1374 "index_labels": index_labels,
1375 "index_targets": index_targets,
1376 "claims": sorted(claims, key=lambda item: (item["path"], item["line"])),
1377 }
1378 )
1379 return sorted(reports, key=lambda item: item["name"]), findings
1380
1381
1382 def _has_schema_grammar_signal(line: str, schema_field: str) -> bool:
1383 heading = _SCHEMA_HEADING_RE.match(line)
1384 return bool(
1385 (heading and heading.group(1) == schema_field)
1386 or re.search(r"\b(formats?|grammars?|schemas?|syntaxes?|keys?|values?)\b", line, re.I)
1387 or re.search(r"P<NN>\s*:", line)
1388 or re.search(rf"{re.escape(schema_field)}\s*[:=]", line)
1389 or re.search(rf'"{re.escape(schema_field)}"\s*:', line)
1390 or (" | " in line and re.search(r"<[^>]+>", line))
1391 )
1392
1393
1394 def audit_schema_grammars(
1395 root: Path,
1396 configs: list[dict[str, Any]],
1397 documents: list[Document],
1398 ) -> tuple[list[dict[str, Any]], list[Finding]]:
1399 """Surface fields with grammar-like definitions in multiple non-owner files."""
1400 findings: list[Finding] = []
1401 results: list[dict[str, Any]] = []
1402 document_map = {document.path: document for document in documents}
1403
1404 for config in configs:
1405 source = config.get("source")
1406 if not isinstance(source, str) or not (root / source).is_file():
1407 raise AuditError(f"Schema source does not exist: {source}")
1408 source_text = _read_utf8(root / source)
1409 configured_fields = config.get("fields")
1410 if configured_fields is None:
1411 fields = sorted(set(_SCHEMA_HEADING_RE.findall(source_text)))
1412 elif isinstance(configured_fields, list):
1413 fields = sorted(str(item) for item in configured_fields)
1414 else:
1415 raise AuditError("schema_grammars fields must be a list when present")
1416 scan_patterns = config.get("scan", ["skills/ppt-master/**/*.md"])
1417
1418 for schema_field in fields:
1419 owner_defines_field = any(
1420 re.search(
1421 rf"(?<![A-Za-z0-9_]){re.escape(schema_field)}(?![A-Za-z0-9_])",
1422 line,
1423 )
1424 and _has_schema_grammar_signal(line, schema_field)
1425 for line in source_text.splitlines()
1426 )
1427 if not owner_defines_field:
1428 raise AuditError(
1429 f"Schema owner {source} does not define configured field {schema_field}"
1430 )
1431 sites: list[dict[str, Any]] = []
1432 field_re = re.compile(rf"(?<![A-Za-z0-9_]){re.escape(schema_field)}(?![A-Za-z0-9_])")
1433 for path, document in sorted(document_map.items()):
1434 if path == source or not _matches_any(path, scan_patterns):
1435 continue
1436 in_fence = False
1437 for line_number, line in enumerate(document.text.splitlines(), start=1):
1438 if _FENCE_RE.match(line):
1439 in_fence = not in_fence
1440 continue
1441 if in_fence or not field_re.search(line):
1442 continue
1443 if not _has_schema_grammar_signal(line, schema_field):
1444 continue
1445 sites.append(
1446 {
1447 "path": path,
1448 "line": line_number,
1449 "excerpt": line.strip()[:240],
1450 }
1451 )
1452
1453 unique_files = sorted({site["path"] for site in sites})
1454 if not unique_files:
1455 continue
1456 results.append(
1457 {
1458 "field": schema_field,
1459 "owner": source,
1460 "definition_candidates": sites,
1461 }
1462 )
1463 findings.append(
1464 Finding(
1465 severity="warning",
1466 code="SCHEMA_MULTIDEF_CANDIDATE",
1467 message=(
1468 f"owns {schema_field}, but {len(unique_files)} non-owner "
1469 "files carry grammar-like text for it"
1470 ),
1471 path=source,
1472 related=unique_files,
1473 )
1474 )
1475 results.sort(key=lambda item: item["field"])
1476 return results, findings
1477
1478
1479 def _finding_key(finding: Finding) -> tuple[Any, ...]:
1480 return (
1481 _SEVERITY_ORDER.get(finding.severity, 9),
1482 finding.code,
1483 finding.path,
1484 finding.line,
1485 finding.message,
1486 )
1487
1488
1489 def run_audit(
1490 root: Path,
1491 manifest_path: Path,
1492 *,
1493 include_near_duplicates: bool = True,
1494 ) -> dict[str, Any]:
1495 """Run the complete read-only prompt audit and return a stable report."""
1496 root = root.resolve()
1497 manifest = load_manifest(manifest_path)
1498 encoding_name = str(manifest["encoding"])
1499 paths = discover_documents(root, manifest["documents"])
1500 documents = count_documents(root, paths, encoding_name)
1501 token_counts = {document.path: document.tokens for document in documents}
1502 findings: list[Finding] = []
1503 try:
1504 manifest_label = _relative_path(root, manifest_path)
1505 except ValueError:
1506 manifest_label = str(manifest_path.resolve())
1507
1508 corpus_budget = manifest["documents"].get("max_tokens")
1509 corpus_tokens = sum(token_counts.values())
1510 if isinstance(corpus_budget, int) and corpus_tokens > corpus_budget:
1511 findings.append(
1512 Finding(
1513 severity="error",
1514 code="BUDGET_CORPUS",
1515 message=f"Corpus has {corpus_tokens} tokens; budget is {corpus_budget}",
1516 )
1517 )
1518
1519 findings.extend(audit_file_budgets(manifest.get("file_budgets", {}), token_counts))
1520 registry_configs = manifest.get("registries", [])
1521 registry_members: dict[str, set[Any]] = {}
1522 for registry_config in registry_configs:
1523 registry_name = registry_config.get("name")
1524 if not isinstance(registry_name, str) or not registry_name:
1525 raise AuditError("Every registry requires a name")
1526 if registry_name in registry_members:
1527 raise AuditError(f"Duplicate registry name: {registry_name}")
1528 members, _, _, _ = _registry_ids(root, registry_config)
1529 registry_members[registry_name] = members
1530
1531 load_sets, load_findings, covered_paths = audit_load_sets(
1532 root,
1533 manifest["load_sets"],
1534 token_counts,
1535 manifest_label,
1536 registry_members,
1537 )
1538 findings.extend(load_findings)
1539
1540 coverage, coverage_findings = audit_load_coverage(
1541 token_counts.keys(),
1542 covered_paths,
1543 manifest.get("coverage", {}),
1544 manifest_label,
1545 )
1546 findings.extend(coverage_findings)
1547
1548 duplicate_config = manifest.get("duplicates", {})
1549 accepted_config = duplicate_config.get("accepted", [])
1550 accepted_used: set[tuple[str, str, tuple[str, ...]]] = set()
1551 paragraphs = extract_paragraphs(documents, duplicate_config)
1552 registry_blocks = extract_registry_blocks(documents)
1553 exact, _ = find_exact_duplicates(paragraphs)
1554 exact, exact_accepted = _partition_accepted(
1555 exact,
1556 "exact",
1557 accepted_config,
1558 accepted_used,
1559 )
1560 exact_total = len(exact)
1561 exact = exact[: int(duplicate_config.get("max_exact_results", 100))]
1562 if include_near_duplicates:
1563 near, _ = find_near_duplicates(paragraphs, duplicate_config)
1564 near, near_accepted = _partition_accepted(
1565 near,
1566 "near",
1567 accepted_config,
1568 accepted_used,
1569 )
1570 near_total = len(near)
1571 near = near[: int(duplicate_config.get("max_near_results", 100))]
1572 else:
1573 near, near_total, near_accepted = [], None, []
1574
1575 scanned_duplicate_kinds = {"exact"}
1576 if include_near_duplicates:
1577 scanned_duplicate_kinds.add("near")
1578 for entry in accepted_config:
1579 identity = _accepted_identity(
1580 entry["kind"],
1581 entry["fingerprint"],
1582 entry["paths"],
1583 )
1584 if entry["kind"] in scanned_duplicate_kinds and identity not in accepted_used:
1585 findings.append(
1586 Finding(
1587 severity="error",
1588 code="DUPLICATE_ACCEPTED_STALE",
1589 message=(
1590 "duplicates.accepted entry matches no current duplicate; "
1591 f"remove or update {entry['kind']} {entry['fingerprint']} "
1592 f"for {entry['paths']}"
1593 ),
1594 path=manifest_label,
1595 )
1596 )
1597
1598 if exact_total:
1599 findings.append(
1600 Finding(
1601 severity="warning",
1602 code="DUPLICATE_EXACT_CANDIDATES",
1603 message=f"Found {exact_total} cross-file exact paragraph groups",
1604 )
1605 )
1606 if near_total:
1607 findings.append(
1608 Finding(
1609 severity="warning",
1610 code="DUPLICATE_NEAR_CANDIDATES",
1611 message=f"Found {near_total} cross-file near-duplicate paragraph pairs",
1612 )
1613 )
1614
1615 references, reference_findings = extract_references(root, documents)
1616 findings.extend(reference_findings)
1617 reference_cycles = _strongly_connected_components(
1618 (edge.source, edge.target)
1619 for edge in references
1620 if edge.source.endswith(".md") and edge.target.endswith(".md")
1621 )
1622 authority_candidates = [edge for edge in references if edge.authority_candidate]
1623 authority_candidate_cycles = _strongly_connected_components(
1624 (edge.source, edge.target) for edge in authority_candidates
1625 )
1626 authority_edges, authority_cycles, authority_findings = audit_authority_graph(
1627 root,
1628 manifest.get("authority_edges", []),
1629 )
1630 findings.extend(authority_findings)
1631 reference_pairs = {(edge.source, edge.target) for edge in references}
1632 for edge in authority_edges:
1633 if (edge["from"], edge["to"]) not in reference_pairs:
1634 findings.append(
1635 Finding(
1636 severity="error",
1637 code="AUTHORITY_EDGE_UNREFERENCED",
1638 message=(
1639 f"Declared authority edge has no Markdown reference: "
1640 f"{edge['from']} -> {edge['to']}"
1641 ),
1642 path=manifest_label,
1643 )
1644 )
1645
1646 registries, registry_findings = audit_registries(
1647 root,
1648 registry_configs,
1649 registry_blocks,
1650 documents,
1651 )
1652 findings.extend(registry_findings)
1653 schema_grammars, schema_findings = audit_schema_grammars(
1654 root,
1655 manifest.get("schema_grammars", []),
1656 documents,
1657 )
1658 findings.extend(schema_findings)
1659
1660 findings.sort(key=_finding_key)
1661 serialized_findings = [asdict(finding) for finding in findings]
1662 errors = sum(finding.severity == "error" for finding in findings)
1663 warnings = sum(finding.severity == "warning" for finding in findings)
1664
1665 return {
1666 "schema_version": 1,
1667 "encoding": encoding_name,
1668 "manifest": {
1669 "audit_only": manifest["audit_only"],
1670 "runtime_consumed": manifest["runtime_consumed"],
1671 "budget_policy": manifest["budget_policy"],
1672 },
1673 "summary": {
1674 "files": len(documents),
1675 "tokens": corpus_tokens,
1676 "max_tokens": corpus_budget,
1677 "errors": errors,
1678 "warnings": warnings,
1679 },
1680 "files": [
1681 {"path": path, "tokens": tokens}
1682 for path, tokens in sorted(token_counts.items(), key=lambda item: (-item[1], item[0]))
1683 ],
1684 "load_sets": load_sets,
1685 "coverage": coverage,
1686 "duplicates": {
1687 "exact_total": exact_total,
1688 "exact": exact,
1689 "exact_accepted": exact_accepted,
1690 "near_scanned": include_near_duplicates,
1691 "near_total": near_total,
1692 "near": near,
1693 "near_accepted": near_accepted,
1694 },
1695 "references": {
1696 "edges": [asdict(edge) for edge in references],
1697 "cycles": reference_cycles,
1698 "authority_candidates": [asdict(edge) for edge in authority_candidates],
1699 "authority_candidate_cycles": authority_candidate_cycles,
1700 "declared_authority_edges": authority_edges,
1701 "declared_authority_cycles": authority_cycles,
1702 },
1703 "registries": registries,
1704 "schema_grammars": schema_grammars,
1705 "findings": serialized_findings,
1706 }
1707
1708
1709 def render_text(report: dict[str, Any]) -> str:
1710 """Render the stable JSON report as a maintainer-oriented text summary."""
1711 summary = report["summary"]
1712 lines = [
1713 "PPT Master Prompt Audit",
1714 "=======================",
1715 "Manifest: audit-only | runtime loading: disabled | budgets: fixed upper bounds",
1716 (
1717 f"Corpus: {summary['files']} files | {summary['tokens']} tokens "
1718 f"(budget {summary['max_tokens']})"
1719 ),
1720 (
1721 f"Coverage: {report['coverage']['covered']} in load sets | "
1722 f"{report['coverage']['exempt']} exempt | "
1723 f"{len(report['coverage']['uncovered'])} uncovered"
1724 ),
1725 f"Findings: {summary['errors']} error(s) | {summary['warnings']} warning(s)",
1726 "",
1727 "Load sets (min / typical / max <= budget):",
1728 ]
1729 for item in report["load_sets"]:
1730 tokens = item["tokens"]
1731 lines.append(
1732 f" {item['status'].upper():4} {item['name']}: "
1733 f"{tokens['min']} / {tokens['typical']} / {tokens['max']} <= {item['max_tokens']}"
1734 )
1735
1736 lines.extend(["", "Registries:"])
1737 for item in report["registries"]:
1738 bounds = ""
1739 if item["minimum_id"] is not None:
1740 bounds = f" | ids {item['minimum_id']}..{item['maximum_id']}"
1741 lines.append(f" {item['name']}: {item['entries']} entries{bounds}")
1742
1743 references = report["references"]
1744 duplicates = report["duplicates"]
1745 near_summary = (
1746 (
1747 f"{duplicates['near_total']} near pair(s) + "
1748 f"{len(duplicates['near_accepted'])} accepted"
1749 )
1750 if duplicates["near_scanned"]
1751 else "near scan skipped"
1752 )
1753 lines.extend(
1754 [
1755 "",
1756 "Candidates:",
1757 (
1758 f" duplicate paragraphs: {duplicates['exact_total']} exact group(s) + "
1759 f"{len(duplicates['exact_accepted'])} accepted, "
1760 f"{near_summary}"
1761 ),
1762 (
1763 f" reference graph: {len(references['edges'])} edge(s), "
1764 f"{len(references['cycles'])} cyclic component(s)"
1765 ),
1766 (
1767 f" authority candidates: {len(references['authority_candidates'])} edge(s), "
1768 f"{len(references['authority_candidate_cycles'])} candidate cycle(s)"
1769 ),
1770 f" schema multi-definition candidates: {len(report['schema_grammars'])}",
1771 ]
1772 )
1773 if duplicates["exact"]:
1774 lines.append(" exact examples:")
1775 for item in duplicates["exact"][:5]:
1776 locations = item["locations"][:2]
1777 lines.append(
1778 " " + " <-> ".join(f"{site['path']}:{site['line']}" for site in locations)
1779 )
1780 if duplicates["near_scanned"] and duplicates["near"]:
1781 lines.append(" near examples:")
1782 for item in duplicates["near"][:5]:
1783 left = item["left"]
1784 right = item["right"]
1785 lines.append(
1786 f" {left['path']}:{left['line']} <-> {right['path']}:{right['line']} "
1787 f"({item['similarity']:.2f})"
1788 )
1789 lines.extend(["", "Per-file tokens:"])
1790 for item in report["files"]:
1791 lines.append(f" {item['tokens']:7d} {item['path']}")
1792
1793 lines.extend(["", "Findings:"])
1794 if not report["findings"]:
1795 lines.append(" none")
1796 else:
1797 for finding in report["findings"]:
1798 location = finding["path"]
1799 if location and finding["line"]:
1800 location += f":{finding['line']}"
1801 prefix = f" {location}" if location else ""
1802 lines.append(
1803 f" [{finding['severity'].upper()} {finding['code']}]{prefix} "
1804 f"{finding['message']}"
1805 )
1806 return "\n".join(lines) + "\n"
1807
1808
1809 def build_parser() -> argparse.ArgumentParser:
1810 parser = argparse.ArgumentParser(
1811 description="Audit PPT Master's prompt budget and governance metadata without writes.",
1812 formatter_class=argparse.RawDescriptionHelpFormatter,
1813 )
1814 default_root = Path(__file__).resolve().parents[3]
1815 default_manifest = Path(__file__).with_name("prompt_audit_manifest.json")
1816 parser.add_argument(
1817 "--root",
1818 type=Path,
1819 default=default_root,
1820 help=f"Repository root (default: {default_root})",
1821 )
1822 parser.add_argument(
1823 "--manifest",
1824 type=Path,
1825 default=default_manifest,
1826 help=f"Audit manifest (default: {default_manifest})",
1827 )
1828 parser.add_argument("--json", action="store_true", help="Emit the complete report as JSON")
1829 parser.add_argument(
1830 "--skip-near-duplicates",
1831 action="store_true",
1832 help="Skip the slower heuristic near-duplicate pass",
1833 )
1834 return parser
1835
1836
1837 def main(argv: list[str] | None = None) -> int:
1838 parser = build_parser()
1839 args = parser.parse_args(argv)
1840 root = args.root.resolve()
1841 manifest_path = args.manifest
1842 if not manifest_path.is_absolute():
1843 manifest_path = root / manifest_path
1844 try:
1845 report = run_audit(
1846 root,
1847 manifest_path,
1848 include_near_duplicates=not args.skip_near_duplicates,
1849 )
1850 except AuditError as exc:
1851 if args.json:
1852 print(
1853 json.dumps(
1854 {
1855 "schema_version": 1,
1856 "error": {
1857 "code": "AUDIT_SETUP_ERROR",
1858 "message": str(exc),
1859 },
1860 },
1861 ensure_ascii=False,
1862 indent=2,
1863 )
1864 )
1865 else:
1866 print(f"Error: {exc}", file=sys.stderr)
1867 return 1
1868
1869 if args.json:
1870 print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=False))
1871 else:
1872 print(render_text(report), end="")
1873 return 1 if report["summary"]["errors"] else 0
1874
1875
1876 if __name__ == "__main__":
1877 raise SystemExit(main())
1878
1878 lines PYTHON