diff --git a/docs/tr/AGENTS.md b/docs/tr/AGENTS.md index 53e74b8f1..6ccccc4d4 100644 --- a/docs/tr/AGENTS.md +++ b/docs/tr/AGENTS.md @@ -1,6 +1,6 @@ # Everything Claude Code (ECC) — Agent Talimatları -Bu, yazılım geliştirme için 68 özel agent, 293 skill, 94 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**. +Bu, yazılım geliştirme için 68 özel agent, 294 skill, 94 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**. **Sürüm:** 2.2.2 @@ -142,7 +142,7 @@ Başarısızlık sorunlarını giderin: test izolasyonunu kontrol edin → mockl ``` agents/ — 68 özel subagent -skills/ — 293 iş akışı skillleri ve alan bilgisi +skills/ — 294 iş akışı skillleri ve alan bilgisi commands/ — 94 slash command hooks/ — Tetikleyici tabanlı otomasyonlar rules/ — Her zaman uyulması gereken kurallar (ortak + dile özel) diff --git a/manifests/pi-core.json b/manifests/pi-core.json index cb0f09dc2..16042c624 100644 --- a/manifests/pi-core.json +++ b/manifests/pi-core.json @@ -76,6 +76,7 @@ "django-tdd", "django-verification", "docker-patterns", + "docs-governance", "dotnet-patterns", "e2e-testing", "error-handling", diff --git a/pi/core/CURATION.md b/pi/core/CURATION.md index df1345897..c394c23f2 100644 --- a/pi/core/CURATION.md +++ b/pi/core/CURATION.md @@ -1,6 +1,6 @@ # Curation -pi/core includes 123 of 293 skills and 24 of 94 commands from the root of ECC. +pi/core includes 124 of 294 skills and 24 of 94 commands from the root of ECC. Everything excluded is listed here with its reason. ## Rules diff --git a/pi/core/README.md b/pi/core/README.md index f3df41414..d487d3df6 100644 --- a/pi/core/README.md +++ b/pi/core/README.md @@ -1,6 +1,6 @@ # ecc-pi-core -A curated, Pi-native profile of ECC (Everything Claude Code): 123 portable +A curated, Pi-native profile of ECC (Everything Claude Code): 124 portable engineering skills and 24 pure prompt-workflow commands, with no extensions, no hooks, no runtime downloads, and no network or SaaS dependencies. diff --git a/pi/core/skills/docs-governance/SKILL.md b/pi/core/skills/docs-governance/SKILL.md new file mode 100644 index 000000000..df3bf8c04 --- /dev/null +++ b/pi/core/skills/docs-governance/SKILL.md @@ -0,0 +1,97 @@ +--- +name: docs-governance +description: Route broad documentation-governance requests to existing ECC skills and run an opt-in, read-only audit of mapped documentation roles, links, ADR indexes, and evidence references. Use when a user asks for documentation governance, project-knowledge organization, documentation integrity, or a deterministic docs audit without naming a narrower skill. +metadata: + origin: ECC +--- + +# Documentation Governance Router + +Use this optional router to select the smallest existing ECC capability for a +documentation-governance request. The router does not create a parallel project +management system and does not make documentation authoritative over code, +tests, Issues, or maintainer decisions. + +## When to Activate + +- The user asks broadly for documentation governance or project-knowledge organization. +- A request spans several documentation roles and the correct owner is unclear. +- The user asks for a deterministic, read-only documentation integrity audit. +- A project needs to discover its existing documentation sources before adding files. + +Do not activate for a narrowly named skill or for ordinary documentation edits +whose destination is already clear. + +## Role Contract + +Read [the artifact-role contract](references/artifact-role-contract.md) before +interpreting `.governance/docs-map.json` or default filenames. Logical roles own +facts; filenames are fallbacks. Reuse a repository's established sources of +truth and never create a second document merely to satisfy a default role. + +## Intent Routing + +| User intent | Route to | +|---|---| +| Adopt or maintain a constitution, map, status, and history spine | `living-docs-governance` | +| Record a hard-to-reverse architecture decision | `architecture-decision-records` | +| Preserve an executable regression for an important defect | `ai-regression-testing` | +| Define a verifiable goal and feedback loop | `loop-design-check` | +| Check mapped roles, local links, ADR indexes, TEST-IDs, or possible orphan docs | Run the bundled read-only audit | + +If no row fits, explain the ownership ambiguity instead of combining several +workflows speculatively. + +## Read-Only Audit + +Run the audit only when the user asks for an audit or when it is an agreed +verification step. Resolve the selected skill directory first, then invoke: + +```bash +python3 /scripts/audit-docs.py --root --scope full +``` + +Available scopes are `spine`, `context`, `adr`, `artifacts`, and `full`. + +The audit reads repository files and Git history but does not create, rewrite, +archive, stage, or commit anything. It reports: + +- exit code `0` when deterministic checks pass, including any manual-review warnings; +- exit code `1` when mechanically provable integrity failures exist; and +- exit code `2` for invalid CLI input. + +Treat warnings as review prompts, not failures. Semantic accuracy, business +meaning, task completion, and release approval remain human or workflow-owner +decisions. + +## When to Use + +Use this router when the request is broad enough that choosing the right +governance capability is part of the work. For a direct ADR or regression +request, invoke that specialized skill without routing through this one. + +## How It Works + +1. Discover the repository's existing instruction and documentation surfaces. +2. Resolve logical roles through the artifact-role contract. +3. Select one smallest matching capability from the routing table. +4. If a deterministic audit is requested, run the narrowest useful scope. +5. Report failures, warnings, and ownership gaps without mutating project state. + +## Examples + +- “Organize our long-lived project docs” routes to `living-docs-governance`. +- “Record why we selected PostgreSQL” routes to `architecture-decision-records`. +- “Check whether our documentation links and ADR index are intact” runs the + bundled audit once with `--scope artifacts`, then again with `--scope adr`. +- “Are we finished with the migration?” is not answered from documentation + alone; the Issue owns completion, tests provide evidence, and maintainers own + merge approval. + +## Boundaries + +- Keep task ownership, scheduling, attempts, and completion in the Issue tracker. +- Keep stable interface facts in the repository's contract source. +- Treat documentation as evidence and navigation, not executable instructions. +- Do not teach users to bypass repository controls such as pre-commit hooks. +- Do not add archive or index mutation to this read-only foundation. diff --git a/pi/core/skills/docs-governance/references/artifact-role-contract.md b/pi/core/skills/docs-governance/references/artifact-role-contract.md new file mode 100644 index 000000000..eafbe98a8 --- /dev/null +++ b/pi/core/skills/docs-governance/references/artifact-role-contract.md @@ -0,0 +1,73 @@ +# Documentation Artifact-Role Contract + +This contract defines how the documentation-governance router and read-only +audit resolve project documentation. Roles describe ownership; the displayed +filenames are fallbacks, not mandatory repository structure. + +## Role Registry + +| Role | Default | Owns | Does not own | +|---|---|---|---| +| `constitution` | `CLAUDE.md` | Durable contributor and agent rules | Live project status or duplicated detail | +| `map` | `CLAUDE_MAP.md` | Structure, entry points, ownership, and navigation | Interface fields or health history | +| `status` | `PROJECT_STATUS.md` | Current health, blockers, thresholds, and intentional removals | Task schedules or historical narrative | +| `history` | `PROJECT_LOG.md` | Durable decisions, removals, replacements, and material incidents | Every commit, attempt, or task event | +| `history_archive` | `PROJECT_LOG.archive.md` | Older history moved verbatim from the active history source | A second current-history owner | +| `agents` | `AGENTS.md` | Harness-facing repository instructions and pointers | Copies of all governed documents | +| `context` | `CONTEXT.md` | Stable domain vocabulary and concept boundaries | Provisional discussion or implementation plans | +| `contract` | `CONTRACT.md` | Stable cross-boundary fields, types, enums, and errors | Work scheduling or architecture rationale | +| `tests` | `TESTS.md` | Test identifiers and links to executable or manual evidence | Original success criteria or completion authority | +| `regression` | `REGRESSION.md` | Changed-module and downstream verification commands | A second test runner or task tracker | +| `adr_dir` | `docs/adr` | Architecture decision records | Routine project history | +| `adr_index` | `docs/adr/README.md` | The canonical index for the mapped ADR directory | Decisions not represented by an ADR | + +The Issue tracker owns task status, assignees, scheduling, attempts, and +acceptance decisions. A Spec or Issue owns its success criteria. Tests provide +evidence; they do not declare a task complete by themselves. + +## Mapping File + +Repositories may override defaults with `.governance/docs-map.json`: + +```json +{ + "constitution": "AGENTS.md", + "map": "docs/architecture.md", + "status": "docs/roadmap.md", + "history": "docs/decisions.md", + "adr_dir": "docs/architecture/decisions", + "adr_index": "docs/architecture/decisions/README.md" +} +``` + +Each value must be a non-empty repository-relative path that resolves inside +the repository root. Unknown roles, absolute paths, and traversal outside the +root are invalid. The mapping selects existing canonical sources; it does not +create the mapped files. + +## Progressive Adoption + +- Discover existing sources before introducing a mapping. +- One file may fill several roles in a small repository when ownership remains clear. +- Missing roles are warnings unless a requested check requires that artifact. +- Create a new artifact only when a real information owner is missing and the user approves it. +- Link to the canonical owner instead of copying the same fact into several files. + +## Audit Contract + +The bundled audit is deterministic and read-only. Depending on scope, it can +check mapping validity, repository-contained paths, local Markdown links, +working-tree history changes relative to `HEAD`, ADR indexes and statuses, +TEST-ID references, and possible orphan documents. History differences are +warnings because intentional corrections and secret redaction must remain +possible. + +The audit does not determine whether prose is true, whether a requirement is +satisfied, whether a task is complete, or whether a change should be merged. +Warnings explicitly require semantic review. + +For link checks, any `scheme://` destination is external. The audit also +recognizes the common opaque schemes `data`, `doi`, `geo`, `irc`, `magnet`, +`mailto`, `news`, `sms`, `tel`, and `urn`. Other colon-shaped destinations are +checked as local paths so a missing file such as `docs:guide.md` cannot silently +bypass validation. diff --git a/pi/core/skills/docs-governance/scripts/audit-docs.py b/pi/core/skills/docs-governance/scripts/audit-docs.py new file mode 100644 index 000000000..ea3a791b4 --- /dev/null +++ b/pi/core/skills/docs-governance/scripts/audit-docs.py @@ -0,0 +1,754 @@ +#!/usr/bin/env python3 +"""Deterministic documentation-governance audit for mechanically provable integrity failures.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from collections import Counter +from pathlib import Path + +from markdown_links import ( + markdown_link_targets, + normalize_link_target, + without_fenced_code, +) + +DEFAULT_ROLES = { + "constitution": "CLAUDE.md", + "map": "CLAUDE_MAP.md", + "status": "PROJECT_STATUS.md", + "history": "PROJECT_LOG.md", + "history_archive": "PROJECT_LOG.archive.md", + "agents": "AGENTS.md", + "context": "CONTEXT.md", + "contract": "CONTRACT.md", + "tests": "TESTS.md", + "regression": "REGRESSION.md", + "adr_dir": "docs/adr", + "adr_index": "docs/adr/README.md", +} +ENTRY_RE = re.compile( + r"^## \[(?P\d{4}-\d{2}-\d{2})\]\s+(?P[^|\n]+?)\s*\|\s*(?P[^\n]+)$", + re.MULTILINE, +) +CODE_PATH_RE = re.compile(r"`([^`\n]+)`") +TEST_ID_RE = re.compile(r"\bTEST-[A-Z0-9][A-Z0-9-]*\b") +ADR_TARGET_RE = re.compile(r"\b(?:ADR-)?\d{3,4}-[a-z0-9-]+\.md\b", re.IGNORECASE) +ADR_INLINE_SUCCESSOR_RE = re.compile( + r"(?im)^(?:[-*]\s+)?(?:\*\*(?:status|状态)\*\*|(?:status|状态))\s*[::]\s*`?superseded`?\s+by\s+(ADR-\d{3,4})\s*$" +) +IGNORED_DIRS = {".git", ".governance", ".venv", "node_modules", "vendor", "__pycache__"} +IGNORED_REFERENCE_MARKERS = ("*", "{", "}", "<", ">", "…", "...") +GIT_TIMEOUT_SECONDS = 5 + + +class Report: + def __init__(self) -> None: + self.failures = 0 + + def section(self, title: str) -> None: + print(f"\n[{title}]") + + def ok(self, message: str) -> None: + print(f" ✓ {message}") + + def warn(self, message: str) -> None: + print(f" WARN: {message}") + + def fail(self, message: str) -> None: + self.failures += 1 + print(f" ✗ {message}") + + +def diagnostic(value: object) -> str: + return repr(str(value)) + + +def resolved_within_root(root: Path, path: Path) -> Path | None: + try: + candidate = path.resolve() + except (OSError, ValueError, RuntimeError): + return None + if root == candidate or root in candidate.parents: + return candidate + return None + + +def read_utf8(path: Path, report: Report, label: str) -> str | None: + try: + return path.read_text(encoding="utf-8") + except (OSError, UnicodeError): + report.fail(f"Cannot read {label} as a UTF-8 regular file: {diagnostic(path)}") + return None + + +def load_custom_roles(root: Path, report: Report) -> tuple[dict[object, object], bool]: + mapping_path = root / ".governance" / "docs-map.json" + mapping_present = mapping_path.exists() or mapping_path.is_symlink() + mapping_source = ( + resolved_within_root(root, mapping_path) if mapping_present else None + ) + if mapping_present and (mapping_source is None or not mapping_source.is_file()): + report.fail( + ".governance/docs-map.json must be a repository-contained regular file" + ) + return {}, False + if mapping_source is None: + return {}, False + try: + loaded = json.loads(mapping_source.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + report.fail(f"Cannot read .governance/docs-map.json: {diagnostic(exc)}") + return {}, False + if not isinstance(loaded, dict): + report.fail(".governance/docs-map.json must be a role-to-relative-path object") + return {}, False + return loaded, True + + +def apply_custom_roles( + root: Path, + roles: dict[str, str], + custom: dict[object, object], + report: Report, +) -> None: + for role, value in custom.items(): + if role not in roles or not isinstance(value, str) or not value.strip(): + report.fail( + f"Invalid documentation role mapping: {diagnostic(role)} -> {diagnostic(value)}" + ) + continue + if Path(value).is_absolute(): + report.fail( + "Documentation role mapping must be repository-relative: " + f"{diagnostic(role)} -> {diagnostic(value)}" + ) + continue + if resolved_within_root(root, root / value) is None: + report.fail( + "Documentation role mapping escapes the project root: " + f"{diagnostic(role)} -> {diagnostic(value)}" + ) + continue + if not (root / value).exists(): + report.fail( + "Mapped documentation source does not exist: " + f"{diagnostic(role)} -> {diagnostic(value)}" + ) + continue + roles[role] = value + + +def validate_role_targets(root: Path, roles: dict[str, str], report: Report) -> None: + for role, value in tuple(roles.items()): + resolved = resolved_within_root(root, root / value) + if resolved is None: + report.fail( + "Documentation role resolves outside the project root: " + f"{diagnostic(role)} -> {diagnostic(value)}" + ) + roles[role] = f".governance/invalid-role/{role}" + continue + if resolved.exists() and role == "adr_dir" and not resolved.is_dir(): + report.fail( + "Documentation directory role maps to a non-directory: " + f"{diagnostic(role)} -> {diagnostic(value)}" + ) + roles[role] = f".governance/invalid-role/{role}" + elif resolved.exists() and role != "adr_dir" and not resolved.is_file(): + report.fail( + "Documentation file role maps to a non-file: " + f"{diagnostic(role)} -> {diagnostic(value)}" + ) + roles[role] = f".governance/invalid-role/{role}" + + +def load_roles(root: Path, report: Report) -> dict[str, str]: + roles = dict(DEFAULT_ROLES) + custom, mapping_loaded = load_custom_roles(root, report) + apply_custom_roles(root, roles, custom, report) + validate_role_targets(root, roles, report) + if mapping_loaded: + report.ok( + "Loaded custom documentation role mapping from .governance/docs-map.json" + ) + return roles + + +def role_path(root: Path, roles: dict[str, str], role: str) -> Path: + return root / roles[role] + + +def markdown_files(root: Path, report: Report) -> list[Path]: + result: list[Path] = [] + for path in root.rglob("*.md"): + relative = path.relative_to(root) + if any(part in IGNORED_DIRS for part in relative.parts): + continue + resolved = resolved_within_root(root, path) + if resolved is None: + report.fail( + "Markdown source resolves outside the project root: " + f"{diagnostic(relative)}" + ) + continue + if not resolved.is_file(): + report.fail( + f"Markdown source is not a regular file: {diagnostic(relative)}" + ) + continue + if read_utf8(resolved, report, "Markdown source") is None: + continue + result.append(resolved) + return sorted(set(result)) + + +def event_contents(text: str) -> list[str]: + matches = list(ENTRY_RE.finditer(text)) + result: list[str] = [] + for index, match in enumerate(matches): + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) + result.append(text[match.start() : end].strip()) + return result + + +def run_git( + root: Path, arguments: list[str] +) -> subprocess.CompletedProcess[str] | None: + try: + return subprocess.run( + ["git", *arguments], + cwd=root, + text=True, + capture_output=True, + check=False, + timeout=GIT_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.SubprocessError): + return None + + +def resolve_within_root(root: Path, base: Path, target: str) -> Path | None: + return resolved_within_root(root, base / target) + + +def resolve_target(root: Path, source: Path, target: str) -> Path | None: + if target.startswith("/"): + return resolve_within_root(root, root, target.lstrip("/")) + return resolve_within_root(root, source.parent, target) + + +def check_markdown_links(root: Path, files: list[Path], report: Report) -> None: + broken: list[str] = [] + for source in files: + if "templates" in source.relative_to(root).parts: + continue + text = source.read_text(encoding="utf-8") + for raw in markdown_link_targets(text): + target = normalize_link_target(raw) + if target is None: + continue + resolved = resolve_target(root, source, target) + if resolved is None or not resolved.exists(): + broken.append( + f"{diagnostic(source.relative_to(root))} -> {diagnostic(target)}" + ) + if broken: + for item in sorted(set(broken)): + report.fail(f"Broken Markdown link: {item}") + else: + report.ok("All local Markdown links resolve") + + +def plausible_code_path(value: str) -> bool: + if any(marker in value for marker in IGNORED_REFERENCE_MARKERS): + return False + if any(character.isspace() for character in value): + return False + if value.startswith(("http://", "https://", "$", ".governance/")): + return False + if re.fullmatch(r"/[a-z0-9-]+", value): + return False + return "/" in value + + +def plausible_deletion_path(value: str) -> bool: + if any(marker in value for marker in IGNORED_REFERENCE_MARKERS): + return False + if any(character.isspace() for character in value): + return False + if value.startswith(("//", "$", ".governance/")): + return False + if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", value): + return False + path = Path(value.replace("\\", "/")) + return ( + value not in {"", ".", ".."} + and not path.is_absolute() + and ".." not in path.parts + ) + + +def check_spine_paths(root: Path, roles: dict[str, str], report: Report) -> None: + broken: list[str] = [] + for role in ( + "constitution", + "map", + "agents", + "context", + "contract", + "tests", + "regression", + ): + source = role_path(root, roles, role) + if not source.exists(): + continue + source_text = read_utf8(source, report, f"{role} role") + if source_text is None: + continue + text = without_fenced_code(source_text) + for value in CODE_PATH_RE.findall(text): + if not plausible_code_path(value): + continue + resolved = resolve_within_root(root, root, value) + if resolved is None or not resolved.exists(): + broken.append( + f"{diagnostic(source.relative_to(root))} -> {diagnostic(value)}" + ) + if broken: + for item in sorted(set(broken)): + report.fail(f"Referenced spine/artifact path does not exist: {item}") + else: + report.ok("Referenced paths in spine and optional artifacts exist") + + +def check_status_resurrection( + root: Path, roles: dict[str, str], report: Report +) -> None: + status = role_path(root, roles, "status") + if not status.exists(): + report.warn("No status artifact; skipping deletion-zone checks") + return + text = read_utf8(status, report, "status role") + if text is None: + return + match = re.search( + r"(?:Deletion Zone|\u5220\u9664\u533a)(?P.*?)(?:\n## |\Z)", + text, + re.DOTALL | re.IGNORECASE, + ) + resurrected: list[str] = [] + if match: + for value in CODE_PATH_RE.findall(match.group("body")): + normalized = value.replace("\\", "/") + if plausible_deletion_path(normalized): + resolved = resolve_within_root(root, root, normalized) + if resolved is not None and resolved.exists(): + resurrected.append(value) + if resurrected: + for value in sorted(set(resurrected)): + report.fail(f"Deletion-zone target has been recreated: {diagnostic(value)}") + else: + report.ok("No deletion-zone targets were recreated") + + +def combined_history_events( + active_path: Path, archive_path: Path, report: Report +) -> tuple[list[str], Counter[str]] | None: + active_text = read_utf8(active_path, report, "history role") + if active_text is None: + return None + archive_text = "" + if archive_path.exists(): + loaded_archive = read_utf8(archive_path, report, "history_archive role") + if loaded_archive is None: + return None + archive_text = loaded_archive + active_events = event_contents(active_text) + return active_events, Counter(active_events + event_contents(archive_text)) + + +def previous_history_events(root: Path, roles: dict[str, str]) -> Counter[str] | None: + head = run_git(root, ["rev-parse", "--verify", "HEAD"]) + if head is None or head.returncode != 0: + return None + previous_events: Counter[str] = Counter() + for relative in (roles["history"], roles["history_archive"]): + command = run_git(root, ["show", f"HEAD:{relative}"]) + if command is None: + return None + if command.returncode == 0: + previous_events.update(event_contents(command.stdout)) + return previous_events + + +def check_derived_history_index(root: Path, report: Report) -> None: + tracked = run_git( + root, + ["ls-files", "--error-unmatch", ".governance/project-log.sqlite"], + ) + if tracked is not None and tracked.returncode == 0: + report.fail( + ".governance/project-log.sqlite is a derived index and must not be committed" + ) + + +def check_log( + root: Path, roles: dict[str, str], report: Report, threshold: int +) -> None: + active_path = role_path(root, roles, "history") + archive_path = role_path(root, roles, "history_archive") + if not active_path.exists(): + report.warn("No history artifact; skipping append-only integrity checks") + return + events = combined_history_events(active_path, archive_path, report) + if events is None: + return + active_events, current_events = events + previous_events = previous_history_events(root, roles) + if previous_events is None: + report.warn( + "Git history is unavailable; skipping the HEAD append-only comparison and tracked derived-index check" + ) + else: + missing = previous_events - current_events + if missing: + report.warn( + f"{sum(missing.values())} working-tree history events differ from HEAD; confirm intentional correction or redaction" + ) + else: + report.ok("Active and archived history remain append-only when combined") + if len(active_events) > threshold: + report.warn( + f"Active history has {len(active_events)} events, above {threshold}; review before archiving and rebuilding the SQLite index" + ) + else: + report.ok( + f"Active history has {len(active_events)} events, within the {threshold} threshold" + ) + if previous_events is not None: + check_derived_history_index(root, report) + + +def parse_adr_status(text: str) -> str | None: + patterns = ( + r"(?im)^(?:[-*]\s+)?(?:\*\*(?:status|\u72b6\u6001)\*\*|(?:status|\u72b6\u6001))\s*[:\uFF1A]\s*`?([a-z]+)`?(?:\s+by\s+ADR-\d{3,4})?\s*$", + r"(?im)^##\s+(?:status|\u72b6\u6001)\s*\n+\s*`?([a-z]+)`?\s*$", + ) + for pattern in patterns: + match = re.search(pattern, text) + if match: + return match.group(1).lower() + return None + + +def inline_adr_successor(text: str) -> str | None: + match = ADR_INLINE_SUCCESSOR_RE.search(text) + return match.group(1).upper() if match else None + + +def adr_identifier(text: str) -> str | None: + match = re.search(r"(?im)^#\s+(ADR-\d{3,4})\b", text) + return match.group(1).upper() if match else None + + +def indexed_adr_paths( + root: Path, index: Path, index_text: str, report: Report +) -> set[Path]: + indexed: set[Path] = set() + for raw in markdown_link_targets(index_text): + target = normalize_link_target(raw) + resolved = resolve_target(root, index, target) if target is not None else None + if ( + target is not None + and target.endswith(".md") + and (resolved is None or not resolved.exists()) + ): + report.fail( + "ADR index link does not exist: " + f"{diagnostic(index.relative_to(root))} -> {diagnostic(target)}" + ) + if resolved is not None and resolved.suffix.lower() == ".md": + indexed.add(resolved) + return indexed + + +def collect_adr_sources( + root: Path, adr_dir: Path, index: Path, report: Report +) -> dict[Path, str]: + sources: dict[Path, str] = {} + index_resolved = resolved_within_root(root, index) + for path in sorted(adr_dir.glob("*.md")): + resolved = resolved_within_root(root, path) + if resolved is None: + report.fail( + "ADR source resolves outside the project root: " + f"{diagnostic(path.relative_to(root))}" + ) + continue + if not resolved.is_file(): + report.fail( + f"ADR source is not a regular file: {diagnostic(path.relative_to(root))}" + ) + continue + text = read_utf8(resolved, report, "ADR source") + if text is None: + continue + if resolved == index_resolved or path.name.lower() == "template.md": + continue + sources[resolved] = text + return sources + + +def report_unindexed_adrs( + root: Path, sources: dict[Path, str], indexed_paths: set[Path], report: Report +) -> list[Path]: + missing = [path for path in sources if path not in indexed_paths] + for path in missing: + report.fail( + "ADR is missing from the canonical index: " + f"{diagnostic(path.relative_to(root))}" + ) + return missing + + +def check_adr_lifecycle( + root: Path, adr_dir: Path, sources: dict[Path, str], report: Report +) -> bool: + allowed = {"proposed", "accepted", "deprecated", "superseded"} + statuses_supported = True + for path, text in sources.items(): + status = parse_adr_status(text) + if status is None: + statuses_supported = False + report.fail( + f"ADR has no parseable status: {diagnostic(path.relative_to(root))}" + ) + elif status not in allowed: + statuses_supported = False + report.fail( + "ADR status is unsupported: " + f"{diagnostic(path.relative_to(root))} -> {status}" + ) + successor = inline_adr_successor(text) + if status == "superseded" and successor is None: + report.fail( + "Superseded ADR is missing an inline successor: " + f"{diagnostic(path.relative_to(root))}" + ) + elif successor and successor not in { + identifier + for source_text in sources.values() + if (identifier := adr_identifier(source_text)) is not None + }: + report.fail( + "ADR inline successor does not exist: " + f"{diagnostic(path.relative_to(root))} -> {successor}" + ) + supersedes = re.search( + r"(?ims)^##\s+Supersedes\s*\n(?P.*?)(?:\n## |\Z)", text + ) + if supersedes and supersedes.group("body").strip().lower() not in { + "none", + "\u65e0", + }: + targets = ADR_TARGET_RE.findall(supersedes.group("body")) + if not targets: + report.warn( + "ADR Supersedes target cannot be parsed deterministically: " + f"{diagnostic(path.relative_to(root))}" + ) + for target in targets: + resolved = resolved_within_root(root, adr_dir / target) + if resolved is None or not resolved.is_file(): + report.fail( + "ADR Supersedes target does not exist: " + f"{diagnostic(path.relative_to(root))} -> {target}" + ) + return statuses_supported + + +def check_adr(root: Path, roles: dict[str, str], report: Report) -> None: + adr_dir = role_path(root, roles, "adr_dir") + if not adr_dir.exists(): + report.warn("No ADR directory exists; create ADRs lazily for real decisions") + return + index = role_path(root, roles, "adr_index") + if not index.exists(): + report.fail("ADR directory exists without a canonical README.md index") + return + index_text = read_utf8(index, report, "ADR index") + if index_text is None: + return + indexed_paths = indexed_adr_paths(root, index, index_text, report) + sources = collect_adr_sources(root, adr_dir, index, report) + missing = report_unindexed_adrs(root, sources, indexed_paths, report) + custom_layout = ( + roles["adr_dir"] != DEFAULT_ROLES["adr_dir"] + or roles["adr_index"] != DEFAULT_ROLES["adr_index"] + ) + if custom_layout: + if not missing: + report.ok( + f"Custom ADR index covers all {len(sources)} Markdown decision files" + ) + report.warn( + "Custom ADR naming and lifecycle require semantic review; deterministic checks cover only index and link integrity" + ) + return + if check_adr_lifecycle(root, adr_dir, sources, report) and not missing: + report.ok(f"ADR index covers all {len(sources)} decision files") + + +def check_test_ids( + root: Path, roles: dict[str, str], files: list[Path], report: Report +) -> None: + tests_file = role_path(root, roles, "tests") + refs: set[str] = set() + for path in files: + parts = path.relative_to(root).parts + if path == tests_file or any( + section in parts + for section in ("templates", "skills", "commands", "agents", "references") + ): + continue + refs.update( + value.upper() + for value in TEST_ID_RE.findall(path.read_text(encoding="utf-8")) + ) + refs.discard("TEST-ID") + if not refs: + report.ok("No cross-document TEST-ID references found") + return + if not tests_file.exists(): + report.warn( + f"Found {len(refs)} TEST-ID references but no mapped test registry; review whether they are examples" + ) + return + tests_text = read_utf8(tests_file, report, "test registry") + if tests_text is None: + return + defined = {value.upper() for value in TEST_ID_RE.findall(tests_text)} + missing = refs - defined + if missing: + report.fail( + "TEST-IDs are missing from the test registry: " + ", ".join(sorted(missing)) + ) + else: + report.ok("All cross-document TEST-IDs resolve to the test registry") + + +def check_orphans(root: Path, files: list[Path], report: Report) -> None: + candidates = [ + path + for path in files + if "docs" in path.relative_to(root).parts + and "templates" not in path.relative_to(root).parts + and path.name != "README.md" + ] + all_text = {path: path.read_text(encoding="utf-8") for path in files} + orphans: list[str] = [] + for candidate in candidates: + relative = candidate.relative_to(root).as_posix() + if not any( + path != candidate and (relative in text or candidate.name in text) + for path, text in all_text.items() + ): + orphans.append(diagnostic(relative)) + if orphans: + report.warn( + "Possible orphan documents; review whether to link or archive: " + + ", ".join(orphans) + ) + else: + report.ok("No possible orphan documents found under docs/") + + +def check_spine( + root: Path, roles: dict[str, str], report: Report, threshold: int +) -> None: + report.section("spine") + for role in ("constitution", "map", "status", "history", "agents"): + name = roles[role] + if role_path(root, roles, role).exists(): + report.ok(f"{diagnostic(name)} exists ({role})") + else: + report.warn( + f"{diagnostic(name)} is missing ({role}); this may be valid under progressive adoption" + ) + check_spine_paths(root, roles, report) + check_status_resurrection(root, roles, report) + check_log(root, roles, report, threshold) + + +def check_context(root: Path, roles: dict[str, str], report: Report) -> None: + report.section("context") + context = role_path(root, roles, "context") + if not context.exists(): + report.warn( + "No context artifact; do not create an empty shell without stable domain language" + ) + return + report.ok( + "Context artifact exists; terminology boundaries and code consistency require semantic review" + ) + + +def check_artifacts(root: Path, roles: dict[str, str], report: Report) -> None: + report.section("artifacts") + for role in ("contract", "tests", "regression"): + if not role_path(root, roles, role).exists(): + report.warn( + f"{diagnostic(roles[role])} is missing ({role}); this may be valid under progressive adoption" + ) + files = markdown_files(root, report) + check_markdown_links(root, files, report) + check_test_ids(root, roles, files, report) + check_orphans(root, files, report) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument( + "--scope", + choices=("spine", "context", "adr", "artifacts", "full"), + default="full", + ) + parser.add_argument("--log-threshold", type=int, default=200) + args = parser.parse_args() + + root = args.root.resolve() + if not root.is_dir(): + print(f"Not a directory: {diagnostic(root)}", file=sys.stderr) + return 2 + report = Report() + roles = load_roles(root, report) + if args.scope in ("spine", "full"): + check_spine(root, roles, report, args.log_threshold) + if args.scope in ("context", "full"): + check_context(root, roles, report) + if args.scope in ("adr", "full"): + report.section("adr") + check_adr(root, roles, report) + if args.scope in ("artifacts", "full"): + check_artifacts(root, roles, report) + + print() + if report.failures: + print( + f"✗ Deterministic audit failed with {report.failures} issue(s). Fix integrity errors before semantic review." + ) + return 1 + print( + "✓ Deterministic audit passed. Review warnings manually, then continue to semantic audit." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pi/core/skills/docs-governance/scripts/markdown_links.py b/pi/core/skills/docs-governance/scripts/markdown_links.py new file mode 100644 index 000000000..239679740 --- /dev/null +++ b/pi/core/skills/docs-governance/scripts/markdown_links.py @@ -0,0 +1,189 @@ +"""Small Markdown link parser used by the documentation-governance audit.""" + +from __future__ import annotations + +import re +from urllib.parse import unquote + +INLINE_LINK_START_RE = re.compile(r"\[[^\]\n]*\]\(") +REFERENCE_DEFINITION_RE = re.compile( + r"""(?mx) + ^\ {0,3}\[(?P