From 1ea94397b58ed56fd7d91355f94f58cfedea223f Mon Sep 17 00:00:00 2001 From: Seekers2001 Date: Thu, 3 Sep 2026 10:00:25 +0800 Subject: [PATCH] feat(skills): add read-only docs governance audit --- .agents/skills/docs-governance/SKILL.md | 97 +++ .../skills/docs-governance/agents/openai.yaml | 7 + .../references/artifact-role-contract.md | 73 ++ .../docs-governance/scripts/audit-docs.py | 800 ++++++++++++++++++ .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- AGENTS.md | 4 +- README.md | 4 +- README.zh-CN.md | 2 +- agent.yaml | 1 + docs/zh-CN/AGENTS.md | 4 +- docs/zh-CN/README.md | 6 +- manifests/install-components.json | 8 + manifests/install-modules.json | 30 + manifests/install-profiles.json | 1 + package.json | 1 + skills/docs-governance/SKILL.md | 97 +++ .../references/artifact-role-contract.md | 73 ++ skills/docs-governance/scripts/audit-docs.py | 800 ++++++++++++++++++ tests/skills/docs-governance.test.js | 82 ++ tests/test_docs_governance_audit.py | 519 ++++++++++++ 21 files changed, 2601 insertions(+), 12 deletions(-) create mode 100644 .agents/skills/docs-governance/SKILL.md create mode 100644 .agents/skills/docs-governance/agents/openai.yaml create mode 100644 .agents/skills/docs-governance/references/artifact-role-contract.md create mode 100644 .agents/skills/docs-governance/scripts/audit-docs.py create mode 100644 skills/docs-governance/SKILL.md create mode 100644 skills/docs-governance/references/artifact-role-contract.md create mode 100644 skills/docs-governance/scripts/audit-docs.py create mode 100644 tests/skills/docs-governance.test.js create mode 100644 tests/test_docs_governance_audit.py diff --git a/.agents/skills/docs-governance/SKILL.md b/.agents/skills/docs-governance/SKILL.md new file mode 100644 index 000000000..6e9e9e9ab --- /dev/null +++ b/.agents/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 with `--scope artifacts` and `--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/.agents/skills/docs-governance/agents/openai.yaml b/.agents/skills/docs-governance/agents/openai.yaml new file mode 100644 index 000000000..9976c710c --- /dev/null +++ b/.agents/skills/docs-governance/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Docs Governance" + short_description: "Route and audit governed project documentation" + brand_color: "#0EA5E9" + default_prompt: "Use $docs-governance to route documentation work or run the opt-in read-only audit." +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/docs-governance/references/artifact-role-contract.md b/.agents/skills/docs-governance/references/artifact-role-contract.md new file mode 100644 index 000000000..eafbe98a8 --- /dev/null +++ b/.agents/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/.agents/skills/docs-governance/scripts/audit-docs.py b/.agents/skills/docs-governance/scripts/audit-docs.py new file mode 100644 index 000000000..7d4c670fa --- /dev/null +++ b/.agents/skills/docs-governance/scripts/audit-docs.py @@ -0,0 +1,800 @@ +#!/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 urllib.parse import unquote + +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, +) +MARKDOWN_LINK_START_RE = re.compile(r"\[[^\]\n]*\]\(") +CODE_PATH_RE = re.compile(r"`([^`\n]+)`") +TEST_ID_RE = re.compile(r"\bTEST-[A-Z0-9][A-Z0-9-]*\b", re.IGNORECASE) +EXTERNAL_URI_RE = re.compile( + r"^(?:[A-Za-z][A-Za-z0-9+.-]*://|(?:data|doi|geo|irc|magnet|mailto|news|sms|tel|urn):)", + re.IGNORECASE, +) +ADR_TARGET_RE = re.compile(r"\b(?:ADR-)?\d{3,4}-[a-z0-9-]+\.md\b", re.IGNORECASE) +IGNORED_DIRS = {".git", ".governance", ".venv", "node_modules", "vendor", "__pycache__"} +IGNORED_REFERENCE_MARKERS = ("*", "{", "}", "<", ">", "…", "...") + + +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 without_fenced_code(text: str) -> str: + output: list[str] = [] + fence_character: str | None = None + fence_length = 0 + for line in text.splitlines(keepends=True): + marker = re.match(r"^ {0,3}(`{3,}|~{3,})(.*)$", line) + if fence_character is None and marker: + fence_character = marker.group(1)[0] + fence_length = len(marker.group(1)) + output.append("\n" if line.endswith("\n") else "") + continue + if fence_character is not None: + closing = re.match( + rf"^ {{0,3}}{re.escape(fence_character)}{{{fence_length},}}[ \t]*(?:\r?\n)?$", + line, + ) + if closing: + fence_character = None + fence_length = 0 + output.append("\n" if line.endswith("\n") else "") + continue + output.append(line) + return "".join(output) + + +def without_inline_code(text: str) -> str: + output: list[str] = [] + cursor = 0 + while cursor < len(text): + if text[cursor] != "`": + output.append(text[cursor]) + cursor += 1 + continue + end_of_marker = cursor + while end_of_marker < len(text) and text[end_of_marker] == "`": + end_of_marker += 1 + marker = text[cursor:end_of_marker] + closing = text.find(marker, end_of_marker) + if closing == -1: + output.append(marker) + cursor = end_of_marker + continue + output.extend( + "\n" if character == "\n" else " " + for character in text[cursor : closing + len(marker)] + ) + cursor = closing + len(marker) + return "".join(output) + + +def git_show(root: Path, relative: str) -> str | None: + command = subprocess.run( + ["git", "show", f"HEAD:{relative}"], + cwd=root, + text=True, + capture_output=True, + check=False, + ) + return command.stdout if command.returncode == 0 else None + + +def markdown_link_targets(text: str) -> list[str]: + text = without_inline_code(without_fenced_code(text)) + targets: list[str] = [] + for match in MARKDOWN_LINK_START_RE.finditer(text): + start = match.end() + if start < len(text) and text[start] == "<": + end = text.find(">", start + 1) + if end != -1: + targets.append(text[start : end + 1]) + continue + depth = 0 + quote: str | None = None + escaped = False + for end in range(start, len(text)): + character = text[end] + if quote is not None: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == quote: + quote = None + continue + if character in {"'", '"'}: + quote = character + continue + if character == "(": + depth += 1 + elif character == ")": + if depth == 0: + targets.append(text[start:end]) + break + depth -= 1 + return targets + + +def normalize_link_target(raw: str) -> str | None: + target = raw.strip() + if target.startswith("<"): + closing = target.find(">") + if closing == -1: + return None + target = target[1:closing] + else: + title = re.search( + r'\s+(?:"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|\([^()]*\))\s*$', target + ) + if title: + target = target[: title.start()] + target = unquote(target.split("#", 1)[0].split("?", 1)[0]) + if not target or target.startswith("//"): + return None + if EXTERNAL_URI_RE.match(target): + return None + return target + + +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 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")): + if plausible_code_path(value): + resolved = resolve_within_root(root, root, value) + 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]: + previous_events: Counter[str] = Counter() + for relative in (roles["history"], roles["history_archive"]): + previous = git_show(root, relative) + if previous is not None: + previous_events.update(event_contents(previous)) + return previous_events + + +def check_derived_history_index(root: Path, report: Report) -> None: + tracked = subprocess.run( + ["git", "ls-files", "--error-unmatch", ".governance/project-log.sqlite"], + cwd=root, + text=True, + capture_output=True, + check=False, + ) + if 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 + missing = previous_history_events(root, roles) - 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" + ) + check_derived_history_index(root, report) + + +def parse_adr_status(text: str) -> str | None: + patterns = ( + r"(?im)^[-*]?\s*(?:status|\u72b6\u6001)\s*[:\uFF1A]\s*`?([a-z]+)`?\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 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}" + ) + 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/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4f3624062..b76630931 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ { "name": "ecc", "source": "./", - "description": "Harness-native ECC operator layer - 68 agents, 286 skills, 94 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses", + "description": "Harness-native ECC operator layer - 68 agents, 287 skills, 94 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses", "version": "2.2.1", "author": { "name": "Affaan Mustafa", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f3e48987a..f63ca8486 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ecc", "version": "2.2.1", - "description": "Harness-native ECC plugin for engineering teams - 68 agents, 286 skills, 94 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses", + "description": "Harness-native ECC plugin for engineering teams - 68 agents, 287 skills, 94 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses", "author": { "name": "Affaan Mustafa", "url": "https://x.com/affaanmustafa" diff --git a/AGENTS.md b/AGENTS.md index 98f6f0ff4..1fd0b77a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Everything Claude Code (ECC) — Agent Instructions -This is a **production-ready AI coding plugin** providing 68 specialized agents, 286 skills, 94 commands, and automated hook workflows for software development. +This is a **production-ready AI coding plugin** providing 68 specialized agents, 287 skills, 94 commands, and automated hook workflows for software development. **Version:** 2.2.1 @@ -154,7 +154,7 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat ``` agents/ — 68 specialized subagents -skills/ — 286 workflow skills and domain knowledge +skills/ — 287 workflow skills and domain knowledge commands/ — 94 slash commands hooks/ — Trigger-based automations rules/ — Always-follow guidelines (common + per-language) diff --git a/README.md b/README.md index 00d3a8ca7..7734a0c77 100644 --- a/README.md +++ b/README.md @@ -162,12 +162,12 @@ Instead of rebuilding that process in every prompt, you install it once and make ECC is MIT-licensed open source. It works best with Claude Code today, has a supported Codex sync path, and provides capability-limited adapters for Cursor, OpenCode, Gemini, Zed, GitHub Copilot, Antigravity, Qwen, and other harnesses. See the [support status matrix](#platform-support) before assuming feature parity. -Access to 68 agents, 286 skills, and 94 legacy command shims, plus hooks, rules, memory, continuous learning, and AgentShield security scanning. The agents are specialized for planning, review, build repair, security, architecture, and domain work. +Access to 68 agents, 287 skills, and 94 legacy command shims, plus hooks, rules, memory, continuous learning, and AgentShield security scanning. The agents are specialized for planning, review, build repair, security, architecture, and domain work. | Included | Count | What it gives you | | ---------------- | ----------: | ------------------------------------------------------------------------------------ | | Agents | 68 agents | Planning, review, build repair, security, architecture, and domain work | -| Skills | 286 skills | TDD, research, security, docs, frontend, data, ML, operations, and more | +| Skills | 287 skills | TDD, research, security, docs, frontend, data, ML, operations, and more | | Commands | 94 commands | Convenient entry points while ECC moves to a skills-first surface | | Hooks and memory | Runtime | Enforcement, session summaries, continuous learning, instincts, and context controls | | Rules | Selective | Always-loaded standards you choose by language or project | diff --git a/README.zh-CN.md b/README.zh-CN.md index 8eb90eba8..1423381f0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -196,7 +196,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/" /plugin list ecc@ecc ``` -**完成!** 你现在可以使用 68 个代理、286 个技能和 94 个命令。 +**完成!** 你现在可以使用 68 个代理、287 个技能和 94 个命令。 ### multi-* 命令需要额外配置 diff --git a/agent.yaml b/agent.yaml index ff7abe065..d18d4b067 100644 --- a/agent.yaml +++ b/agent.yaml @@ -61,6 +61,7 @@ skills: - dmux-workflows - docker-patterns - documentation-lookup + - docs-governance - e2e-testing - energy-procurement - enterprise-agent-ops diff --git a/docs/zh-CN/AGENTS.md b/docs/zh-CN/AGENTS.md index 871719a16..059848857 100644 --- a/docs/zh-CN/AGENTS.md +++ b/docs/zh-CN/AGENTS.md @@ -1,6 +1,6 @@ # Everything Claude Code (ECC) — 智能体指令 -这是一个**生产就绪的 AI 编码插件**,提供 68 个专业代理、286 项技能、94 条命令以及自动化钩子工作流,用于软件开发。 +这是一个**生产就绪的 AI 编码插件**,提供 68 个专业代理、287 项技能、94 条命令以及自动化钩子工作流,用于软件开发。 **版本:** 2.2.1 @@ -147,7 +147,7 @@ ``` agents/ — 68 个专业子代理 -skills/ — 286 个工作流技能和领域知识 +skills/ — 287 个工作流技能和领域知识 commands/ — 94 个斜杠命令 hooks/ — 基于触发的自动化 rules/ — 始终遵循的指导方针(通用 + 每种语言) diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index c8c0e3228..d32942582 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -260,7 +260,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/" /plugin list ecc@ecc ``` -**搞定!** 你现在可以使用 68 个智能体、286 项技能和 94 个命令了。 +**搞定!** 你现在可以使用 68 个智能体、287 项技能和 94 个命令了。 *** @@ -1174,7 +1174,7 @@ opencode |---------|---------------|----------|--------| | 智能体 | PASS: 68 个 | PASS: 12 个 | **Claude Code 领先** | | 命令 | PASS: 94 个 | PASS: 35 个 | **Claude Code 领先** | -| 技能 | PASS: 286 项 | PASS: 37 项 | **Claude Code 领先** | +| 技能 | PASS: 287 项 | PASS: 37 项 | **Claude Code 领先** | | 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** | | 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** | | MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** | @@ -1282,7 +1282,7 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以 |---------|-----------------------|------------|-----------|----------| | **智能体** | 68 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 | | **命令** | 94 | 共享 | 基于指令 | 35 | -| **技能** | 286 | 共享 | 10 (原生格式) | 37 | +| **技能** | 287 | 共享 | 10 (原生格式) | 37 | | **钩子事件** | 8 种类型 | 15 种类型 | SessionStart(1 种类型) | 11 种类型 | | **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | 1 个 SessionStart 引导脚本 | 插件钩子 | | **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 | diff --git a/manifests/install-components.json b/manifests/install-components.json index 8fbc925f6..4ca0a0eda 100644 --- a/manifests/install-components.json +++ b/manifests/install-components.json @@ -186,6 +186,14 @@ "optimization-workflows" ] }, + { + "id": "capability:documentation-governance", + "family": "capability", + "description": "Optional documentation routing and deterministic read-only integrity audits.", + "modules": [ + "documentation-governance" + ] + }, { "id": "capability:prediction-markets", "family": "capability", diff --git a/manifests/install-modules.json b/manifests/install-modules.json index 0e45965a5..bf76c2f03 100644 --- a/manifests/install-modules.json +++ b/manifests/install-modules.json @@ -385,6 +385,36 @@ "cost": "medium", "stability": "stable" }, + { + "id": "documentation-governance", + "kind": "skills", + "description": "Optional documentation-governance routing and deterministic read-only audits.", + "paths": [ + "skills/docs-governance" + ], + "targets": [ + "claude", + "claude-project", + "cursor", + "antigravity", + "codex", + "opencode", + "codebuddy", + "joycode", + "qwen", + "zed", + "hermes", + "openclaw", + "kimi", + "adal" + ], + "dependencies": [ + "workflow-quality" + ], + "defaultInstall": false, + "cost": "light", + "stability": "beta" + }, { "id": "optimization-workflows", "kind": "skills", diff --git a/manifests/install-profiles.json b/manifests/install-profiles.json index 25091775e..4e99b413c 100644 --- a/manifests/install-profiles.json +++ b/manifests/install-profiles.json @@ -81,6 +81,7 @@ "framework-language", "database", "workflow-quality", + "documentation-governance", "skill-unified-memory", "security", "research-apis", diff --git a/package.json b/package.json index 5513e9ddb..73870055e 100644 --- a/package.json +++ b/package.json @@ -373,6 +373,7 @@ "skills/design-system/", "skills/django-celery/", "skills/documentation-lookup/", + "skills/docs-governance/", "skills/ecc-guide/", "skills/ecc-recipes/", "skills/flox-environments/", diff --git a/skills/docs-governance/SKILL.md b/skills/docs-governance/SKILL.md new file mode 100644 index 000000000..6e9e9e9ab --- /dev/null +++ b/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 with `--scope artifacts` and `--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/skills/docs-governance/references/artifact-role-contract.md b/skills/docs-governance/references/artifact-role-contract.md new file mode 100644 index 000000000..eafbe98a8 --- /dev/null +++ b/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/skills/docs-governance/scripts/audit-docs.py b/skills/docs-governance/scripts/audit-docs.py new file mode 100644 index 000000000..7d4c670fa --- /dev/null +++ b/skills/docs-governance/scripts/audit-docs.py @@ -0,0 +1,800 @@ +#!/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 urllib.parse import unquote + +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, +) +MARKDOWN_LINK_START_RE = re.compile(r"\[[^\]\n]*\]\(") +CODE_PATH_RE = re.compile(r"`([^`\n]+)`") +TEST_ID_RE = re.compile(r"\bTEST-[A-Z0-9][A-Z0-9-]*\b", re.IGNORECASE) +EXTERNAL_URI_RE = re.compile( + r"^(?:[A-Za-z][A-Za-z0-9+.-]*://|(?:data|doi|geo|irc|magnet|mailto|news|sms|tel|urn):)", + re.IGNORECASE, +) +ADR_TARGET_RE = re.compile(r"\b(?:ADR-)?\d{3,4}-[a-z0-9-]+\.md\b", re.IGNORECASE) +IGNORED_DIRS = {".git", ".governance", ".venv", "node_modules", "vendor", "__pycache__"} +IGNORED_REFERENCE_MARKERS = ("*", "{", "}", "<", ">", "…", "...") + + +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 without_fenced_code(text: str) -> str: + output: list[str] = [] + fence_character: str | None = None + fence_length = 0 + for line in text.splitlines(keepends=True): + marker = re.match(r"^ {0,3}(`{3,}|~{3,})(.*)$", line) + if fence_character is None and marker: + fence_character = marker.group(1)[0] + fence_length = len(marker.group(1)) + output.append("\n" if line.endswith("\n") else "") + continue + if fence_character is not None: + closing = re.match( + rf"^ {{0,3}}{re.escape(fence_character)}{{{fence_length},}}[ \t]*(?:\r?\n)?$", + line, + ) + if closing: + fence_character = None + fence_length = 0 + output.append("\n" if line.endswith("\n") else "") + continue + output.append(line) + return "".join(output) + + +def without_inline_code(text: str) -> str: + output: list[str] = [] + cursor = 0 + while cursor < len(text): + if text[cursor] != "`": + output.append(text[cursor]) + cursor += 1 + continue + end_of_marker = cursor + while end_of_marker < len(text) and text[end_of_marker] == "`": + end_of_marker += 1 + marker = text[cursor:end_of_marker] + closing = text.find(marker, end_of_marker) + if closing == -1: + output.append(marker) + cursor = end_of_marker + continue + output.extend( + "\n" if character == "\n" else " " + for character in text[cursor : closing + len(marker)] + ) + cursor = closing + len(marker) + return "".join(output) + + +def git_show(root: Path, relative: str) -> str | None: + command = subprocess.run( + ["git", "show", f"HEAD:{relative}"], + cwd=root, + text=True, + capture_output=True, + check=False, + ) + return command.stdout if command.returncode == 0 else None + + +def markdown_link_targets(text: str) -> list[str]: + text = without_inline_code(without_fenced_code(text)) + targets: list[str] = [] + for match in MARKDOWN_LINK_START_RE.finditer(text): + start = match.end() + if start < len(text) and text[start] == "<": + end = text.find(">", start + 1) + if end != -1: + targets.append(text[start : end + 1]) + continue + depth = 0 + quote: str | None = None + escaped = False + for end in range(start, len(text)): + character = text[end] + if quote is not None: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == quote: + quote = None + continue + if character in {"'", '"'}: + quote = character + continue + if character == "(": + depth += 1 + elif character == ")": + if depth == 0: + targets.append(text[start:end]) + break + depth -= 1 + return targets + + +def normalize_link_target(raw: str) -> str | None: + target = raw.strip() + if target.startswith("<"): + closing = target.find(">") + if closing == -1: + return None + target = target[1:closing] + else: + title = re.search( + r'\s+(?:"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|\([^()]*\))\s*$', target + ) + if title: + target = target[: title.start()] + target = unquote(target.split("#", 1)[0].split("?", 1)[0]) + if not target or target.startswith("//"): + return None + if EXTERNAL_URI_RE.match(target): + return None + return target + + +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 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")): + if plausible_code_path(value): + resolved = resolve_within_root(root, root, value) + 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]: + previous_events: Counter[str] = Counter() + for relative in (roles["history"], roles["history_archive"]): + previous = git_show(root, relative) + if previous is not None: + previous_events.update(event_contents(previous)) + return previous_events + + +def check_derived_history_index(root: Path, report: Report) -> None: + tracked = subprocess.run( + ["git", "ls-files", "--error-unmatch", ".governance/project-log.sqlite"], + cwd=root, + text=True, + capture_output=True, + check=False, + ) + if 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 + missing = previous_history_events(root, roles) - 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" + ) + check_derived_history_index(root, report) + + +def parse_adr_status(text: str) -> str | None: + patterns = ( + r"(?im)^[-*]?\s*(?:status|\u72b6\u6001)\s*[:\uFF1A]\s*`?([a-z]+)`?\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 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}" + ) + 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/tests/skills/docs-governance.test.js b/tests/skills/docs-governance.test.js new file mode 100644 index 000000000..63c643452 --- /dev/null +++ b/tests/skills/docs-governance.test.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const { resolveInstallPlan } = require('../../scripts/lib/install-manifests'); + +const ROOT = path.resolve(__dirname, '../..'); +const CANONICAL = path.join(ROOT, 'skills', 'docs-governance'); +const CODEX_MIRROR = path.join(ROOT, '.agents', 'skills', 'docs-governance'); +const MIRRORED_FILES = [ + 'SKILL.md', + 'references/artifact-role-contract.md', + 'scripts/audit-docs.py', +]; +const ROUTED_SKILLS = [ + 'ai-regression-testing', + 'architecture-decision-records', + 'living-docs-governance', + 'loop-design-check', +]; + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed += 1; + } catch (error) { + console.error(` ✗ ${name}`); + console.error(` ${error.message}`); + failed += 1; + } +} + +console.log('\n=== Documentation governance surface tests ===\n'); + +test('Codex distribution files match the canonical skill', () => { + for (const relativePath of MIRRORED_FILES) { + const canonical = fs.readFileSync(path.join(CANONICAL, relativePath), 'utf8'); + const mirror = fs.readFileSync(path.join(CODEX_MIRROR, relativePath), 'utf8'); + assert.strictEqual(mirror, canonical, `${relativePath} mirror is stale`); + } +}); + +test('router destinations exist on current main', () => { + for (const skill of ROUTED_SKILLS) { + assert.ok( + fs.existsSync(path.join(ROOT, 'skills', skill, 'SKILL.md')), + `docs-governance routes to missing skill: ${skill}` + ); + } +}); + +test('focused surface excludes mutating archive and pre-commit tools', () => { + assert.ok(!fs.existsSync(path.join(CANONICAL, 'scripts', 'project-log-index.py'))); + assert.ok(!fs.existsSync(path.join(CANONICAL, 'templates', 'pre-commit.example'))); +}); + +test('opt-in install includes every routed skill owner', () => { + const plan = resolveInstallPlan({ + repoRoot: ROOT, + profileId: 'minimal', + includeComponentIds: ['capability:documentation-governance'], + target: 'codex', + }); + const installedPaths = new Set(plan.selectedModules.flatMap((module) => module.paths)); + for (const skill of ROUTED_SKILLS) { + assert.ok(installedPaths.has(`skills/${skill}`), `install plan omits routed skill: ${skill}`); + } + assert.ok(installedPaths.has('skills/docs-governance')); +}); + +console.log(`\nPassed: ${passed}`); +console.log(`Failed: ${failed}\n`); + +process.exit(failed === 0 ? 0 : 1); diff --git a/tests/test_docs_governance_audit.py b/tests/test_docs_governance_audit.py new file mode 100644 index 000000000..40ceeb177 --- /dev/null +++ b/tests/test_docs_governance_audit.py @@ -0,0 +1,519 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "skills" / "docs-governance" / "scripts" / "audit-docs.py" + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + return tmp_path + + +def run_audit(project: Path, scope: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT), "--root", str(project), "--scope", scope], + text=True, + capture_output=True, + check=False, + ) + + +def configure_git(project: Path) -> None: + subprocess.run(["git", "init"], cwd=project, capture_output=True, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=project, check=True + ) + subprocess.run(["git", "config", "user.name", "Test"], cwd=project, check=True) + + +def test_artifact_scope_fails_on_broken_markdown_link(project: Path) -> None: + (project / "guide.md").write_text("[missing](docs/missing.md)\n", encoding="utf-8") + result = run_audit(project, "artifacts") + assert result.returncode == 1 + assert "Broken Markdown link" in result.stdout + + +def test_artifact_scope_resolves_root_relative_links_inside_project( + project: Path, +) -> None: + docs = project / "docs" + docs.mkdir() + (docs / "guide.md").write_text("[status](/PROJECT_STATUS.md)\n", encoding="utf-8") + (project / "PROJECT_STATUS.md").write_text("# Status\n", encoding="utf-8") + result = run_audit(project, "artifacts") + assert result.returncode == 0, result.stdout + + +def test_artifact_scope_rejects_links_that_escape_project(project: Path) -> None: + outside = project.parent / "outside.md" + outside.write_text("# Outside\n", encoding="utf-8") + try: + (project / "guide.md").write_text( + "[outside](../outside.md)\n", encoding="utf-8" + ) + result = run_audit(project, "artifacts") + assert result.returncode == 1 + assert "Broken Markdown link" in result.stdout + finally: + outside.unlink(missing_ok=True) + + +def test_artifact_scope_does_not_read_markdown_symlinked_outside_project( + project: Path, +) -> None: + outside = project.parent / "outside-secret.md" + outside.write_text("[secret](missing-token-value.md)\n", encoding="utf-8") + try: + (project / "outside.md").symlink_to(outside) + result = run_audit(project, "artifacts") + assert result.returncode == 1 + assert "Markdown source resolves outside the project root" in result.stdout + assert "missing-token-value" not in result.stdout + finally: + outside.unlink(missing_ok=True) + + +def test_artifact_scope_reports_a_broken_markdown_symlink(project: Path) -> None: + (project / "broken.md").symlink_to(project / "missing.md") + result = run_audit(project, "artifacts") + assert result.returncode == 1 + assert "Markdown source is not a regular file" in result.stdout + + +def test_artifact_scope_reports_non_utf8_markdown(project: Path) -> None: + (project / "invalid.md").write_bytes(b"\xff\xfe") + result = run_audit(project, "artifacts") + assert result.returncode == 1 + assert "Cannot read Markdown source as a UTF-8 regular file" in result.stdout + + +def test_artifact_scope_reports_a_nul_encoded_link_without_crashing( + project: Path, +) -> None: + (project / "guide.md").write_text("[invalid](a%00b.md)\n", encoding="utf-8") + result = run_audit(project, "artifacts") + assert result.returncode == 1 + assert "Broken Markdown link" in result.stdout + assert "Traceback" not in result.stderr + + +def test_artifact_scope_ignores_protocol_relative_external_links(project: Path) -> None: + (project / "guide.md").write_text( + "[asset](//cdn.example.com/file.js)\n", encoding="utf-8" + ) + result = run_audit(project, "artifacts") + assert result.returncode == 0, result.stdout + + +def test_artifact_scope_ignores_external_uri_schemes_case_insensitively( + project: Path, +) -> None: + (project / "guide.md").write_text( + "[secure](HTTPS://example.com/guide)\n" + "[transfer](ftp://example.com/file)\n" + "[identifier](urn:isbn:9780140328721)\n" + "[paper](doi:10.1000/182)\n" + "[text](sms:+15555550123)\n" + "[location](geo:37.786971,-122.399677)\n" + "[mail](mailto:docs@example.com)\n" + "[phone](tel:+15555550123)\n" + "[inline](data:text/plain,hello)\n", + encoding="utf-8", + ) + result = run_audit(project, "artifacts") + assert result.returncode == 0, result.stdout + + +def test_artifact_scope_ignores_opaque_external_uri_schemes(project: Path) -> None: + (project / "guide.md").write_text( + "[magnet](magnet:?xt=urn:btih:abcdef)\n" + "[chat](irc:channel)\n" + "[news](news:comp.lang.python)\n", + encoding="utf-8", + ) + result = run_audit(project, "artifacts") + assert result.returncode == 0, result.stdout + + +def test_artifact_scope_checks_windows_drive_paths_as_local_paths( + project: Path, +) -> None: + (project / "guide.md").write_text( + "[slash](C:/docs/guide.md)\n[backslash](C:\\docs\\guide.md)\n", + encoding="utf-8", + ) + result = run_audit(project, "artifacts") + assert result.returncode == 1 + assert "Broken Markdown link" in result.stdout + + +def test_artifact_scope_checks_colon_shaped_local_paths_as_local_paths( + project: Path, +) -> None: + (project / "guide.md").write_text("[local](docs:guide.md)\n", encoding="utf-8") + result = run_audit(project, "artifacts") + assert result.returncode == 1 + assert "Broken Markdown link: 'guide.md' -> 'docs:guide.md'" in result.stdout + + +def test_artifact_scope_escapes_percent_encoded_control_characters( + project: Path, +) -> None: + (project / "guide.md").write_text( + "[unsafe](missing%0A%1B%5B2J.md)\n", encoding="utf-8" + ) + result = run_audit(project, "artifacts") + assert result.returncode == 1 + assert "missing\\n\\x1b[2J.md" in result.stdout + assert "\x1b" not in result.stdout + + +def test_artifact_scope_ignores_query_and_fragment_in_local_links( + project: Path, +) -> None: + (project / "guide.md").write_text( + "[details](target.md?version=2#usage)\n", encoding="utf-8" + ) + (project / "target.md").write_text("# Target\n", encoding="utf-8") + result = run_audit(project, "artifacts") + assert result.returncode == 0, result.stdout + + +def test_artifact_scope_resolves_markdown_destinations_with_parentheses_and_spaces( + project: Path, +) -> None: + (project / "topic (draft).md").write_text("# Draft\n", encoding="utf-8") + (project / "guide with spaces.md").write_text("# Guide\n", encoding="utf-8") + (project / "index.md").write_text( + "[topic](topic (draft).md)\n[guide]()\n", + encoding="utf-8", + ) + result = run_audit(project, "artifacts") + assert result.returncode == 0, result.stdout + + +def test_artifact_scope_ignores_optional_markdown_link_titles(project: Path) -> None: + (project / "guide.md").write_text("# Guide\n", encoding="utf-8") + (project / "index.md").write_text( + '[plain](guide.md "Guide")\n[wrapped]( "Guide")\n', + encoding="utf-8", + ) + result = run_audit(project, "artifacts") + assert result.returncode == 0, result.stdout + + +def test_artifact_scope_ignores_links_inside_code_examples(project: Path) -> None: + (project / "guide.md").write_text( + "`[inline](missing-inline.md)`\n\n```md\n[fenced](missing-fenced.md)\n```\n", + encoding="utf-8", + ) + result = run_audit(project, "artifacts") + assert result.returncode == 0, result.stdout + + +def test_artifact_scope_ignores_title_with_escaped_quotes(project: Path) -> None: + (project / "guide.md").write_text("# Guide\n", encoding="utf-8") + (project / "index.md").write_text( + '[guide](guide.md "The \\"Guide\\"")\n', + encoding="utf-8", + ) + result = run_audit(project, "artifacts") + assert result.returncode == 0, result.stdout + + +def test_artifact_scope_ignores_title_with_closing_parenthesis(project: Path) -> None: + (project / "guide.md").write_text("# Guide\n", encoding="utf-8") + (project / "index.md").write_text( + '[guide](guide.md "Guide)")\n', + encoding="utf-8", + ) + result = run_audit(project, "artifacts") + assert result.returncode == 0, result.stdout + + +def test_artifact_scope_checks_angle_wrapped_destination_with_title( + project: Path, +) -> None: + (project / "index.md").write_text( + '[missing]( "Guide")\n', encoding="utf-8" + ) + result = run_audit(project, "artifacts") + assert result.returncode == 1 + assert "Broken Markdown link: 'index.md' -> 'guide.md'" in result.stdout + + +def test_adr_scope_requires_every_file_in_index(project: Path) -> None: + adr_dir = project / "docs" / "adr" + adr_dir.mkdir(parents=True) + (adr_dir / "README.md").write_text("# ADR Index\n", encoding="utf-8") + (adr_dir / "0001-storage.md").write_text( + "# ADR-0001\n\nStatus: accepted\n", encoding="utf-8" + ) + result = run_audit(project, "adr") + assert result.returncode == 1 + assert "ADR is missing from the canonical index" in result.stdout + + +def test_adr_scope_accepts_indexed_decision(project: Path) -> None: + adr_dir = project / "docs" / "adr" + adr_dir.mkdir(parents=True) + (adr_dir / "README.md").write_text( + "[decision](ADR-001-Storage.md)\n", encoding="utf-8" + ) + (adr_dir / "ADR-001-Storage.md").write_text( + "# ADR-001\n\nStatus: accepted\n", encoding="utf-8" + ) + result = run_audit(project, "adr") + assert result.returncode == 0, result.stdout + + +def test_adr_scope_audits_common_adr_prefixed_filenames(project: Path) -> None: + adr_dir = project / "docs" / "adr" + adr_dir.mkdir(parents=True) + (adr_dir / "README.md").write_text( + "[decision](ADR-001-Storage.md)\n", encoding="utf-8" + ) + (adr_dir / "ADR-001-Storage.md").write_text("# ADR-001\n", encoding="utf-8") + result = run_audit(project, "adr") + assert result.returncode == 1 + assert "ADR has no parseable status" in result.stdout + + +def test_adr_scope_requires_an_actual_index_link(project: Path) -> None: + adr_dir = project / "docs" / "adr" + adr_dir.mkdir(parents=True) + (adr_dir / "README.md").write_text( + "TODO: add 0001-storage.md later\n", encoding="utf-8" + ) + (adr_dir / "0001-storage.md").write_text( + "# ADR-0001\n\nStatus: accepted\n", encoding="utf-8" + ) + result = run_audit(project, "adr") + assert result.returncode == 1 + assert "ADR is missing from the canonical index" in result.stdout + + +def test_adr_scope_ignores_the_bundled_template(project: Path) -> None: + adr_dir = project / "docs" / "adr" + adr_dir.mkdir(parents=True) + (adr_dir / "README.md").write_text("# ADR index\n", encoding="utf-8") + (adr_dir / "template.md").write_text( + "# Title\n\nStatus: proposed\n", encoding="utf-8" + ) + result = run_audit(project, "adr") + assert result.returncode == 0, result.stdout + + +def test_adr_scope_does_not_read_an_adr_symlinked_outside_project( + project: Path, +) -> None: + adr_dir = project / "docs" / "adr" + adr_dir.mkdir(parents=True) + outside = project.parent / "outside-adr.md" + outside.write_text("private ADR body\n", encoding="utf-8") + try: + (adr_dir / "README.md").write_text( + "[outside](0001-outside.md)\n", encoding="utf-8" + ) + (adr_dir / "0001-outside.md").symlink_to(outside) + result = run_audit(project, "adr") + assert result.returncode == 1 + assert "ADR source resolves outside the project root" in result.stdout + assert "private ADR body" not in result.stdout + finally: + outside.unlink(missing_ok=True) + + +def test_custom_role_map_supports_existing_adr_layout(project: Path) -> None: + adr_dir = project / "docs" / "architecture" / "decisions" + adr_dir.mkdir(parents=True) + (adr_dir / "README.md").write_text("[0001](0001-storage.md)\n", encoding="utf-8") + (adr_dir / "0001-storage.md").write_text( + "# ADR-0001\n\nStatus: accepted\n", encoding="utf-8" + ) + governance = project / ".governance" + governance.mkdir() + (governance / "docs-map.json").write_text( + json.dumps( + { + "adr_dir": "docs/architecture/decisions", + "adr_index": "docs/architecture/decisions/README.md", + } + ), + encoding="utf-8", + ) + result = run_audit(project, "adr") + assert result.returncode == 0, result.stdout + assert "custom documentation role mapping" in result.stdout + + +def test_adr_scope_rejects_missing_index_link(project: Path) -> None: + adr_dir = project / "docs" / "adr" + adr_dir.mkdir(parents=True) + (adr_dir / "README.md").write_text("[missing](0002-missing.md)\n", encoding="utf-8") + result = run_audit(project, "adr") + assert result.returncode == 1 + assert "ADR index link does not exist" in result.stdout + + +def test_adr_scope_rejects_missing_supersedes_target(project: Path) -> None: + adr_dir = project / "docs" / "adr" + adr_dir.mkdir(parents=True) + (adr_dir / "README.md").write_text("[new](0002-new.md)\n", encoding="utf-8") + (adr_dir / "0002-new.md").write_text( + "# ADR-0002\n\nStatus: accepted\n\n## Supersedes\n\n[old](0001-old.md)\n", + encoding="utf-8", + ) + result = run_audit(project, "adr") + assert result.returncode == 1 + assert "ADR Supersedes target does not exist" in result.stdout + + +def test_artifact_scope_rejects_unregistered_test_id_when_tests_ledger_exists( + project: Path, +) -> None: + docs = project / "docs" + docs.mkdir() + (project / "TESTS.md").write_text("# TESTS\n\nTEST-ORDER-001\n", encoding="utf-8") + (docs / "spec.md").write_text( + "Must be verified by TEST-ORDER-002.\n", encoding="utf-8" + ) + result = run_audit(project, "artifacts") + assert result.returncode == 1 + assert "TEST-IDs are missing from the test registry" in result.stdout + + +def test_artifact_scope_reports_a_non_utf8_test_registry(project: Path) -> None: + (project / "TESTS.md").write_bytes(b"\xff\xfe") + (project / "spec.md").write_text("Verified by TEST-ORDER-001.\n", encoding="utf-8") + result = run_audit(project, "artifacts") + assert result.returncode == 1 + assert "Cannot read test registry as a UTF-8 regular file" in result.stdout + assert "Traceback" not in result.stderr + + +def test_log_move_to_archive_preserves_append_only_history(project: Path) -> None: + configure_git(project) + old = "# LOG\n\n## [2026-01-01] init | one\n## [2026-01-02] fix | two\n" + (project / "PROJECT_LOG.md").write_text(old, encoding="utf-8") + subprocess.run(["git", "add", "PROJECT_LOG.md"], cwd=project, check=True) + subprocess.run( + ["git", "commit", "-m", "init"], cwd=project, capture_output=True, check=True + ) + (project / "PROJECT_LOG.md").write_text( + "# LOG\n\n## [2026-01-02] fix | two\n", encoding="utf-8" + ) + (project / "PROJECT_LOG.archive.md").write_text( + "# archive\n\n## [2026-01-01] init | one\n", encoding="utf-8" + ) + result = run_audit(project, "spine") + assert result.returncode == 0, result.stdout + assert ( + "Active and archived history remain append-only when combined" in result.stdout + ) + + +def test_log_duplicate_removal_is_detected(project: Path) -> None: + configure_git(project) + event = "## [2026-01-01] fix | same event\n" + (project / "PROJECT_LOG.md").write_text( + "# LOG\n\n" + event + event, encoding="utf-8" + ) + subprocess.run(["git", "add", "PROJECT_LOG.md"], cwd=project, check=True) + subprocess.run( + ["git", "commit", "-m", "init"], cwd=project, capture_output=True, check=True + ) + (project / "PROJECT_LOG.md").write_text("# LOG\n\n" + event, encoding="utf-8") + result = run_audit(project, "spine") + assert result.returncode == 0, result.stdout + assert "1 working-tree history events differ from HEAD" in result.stdout + + +def test_role_map_rejects_absolute_paths(project: Path) -> None: + governance = project / ".governance" + governance.mkdir() + (governance / "docs-map.json").write_text( + json.dumps({"context": str(project / "CONTEXT.md")}), encoding="utf-8" + ) + result = run_audit(project, "context") + assert result.returncode == 1 + assert "must be repository-relative" in result.stdout + + +def test_role_map_rejects_a_directory_for_a_file_role(project: Path) -> None: + (project / "docs").mkdir() + governance = project / ".governance" + governance.mkdir() + (governance / "docs-map.json").write_text( + json.dumps({"context": "docs"}), encoding="utf-8" + ) + result = run_audit(project, "context") + assert result.returncode == 1 + assert "file role maps to a non-file" in result.stdout + + +def test_role_map_rejects_a_missing_mapped_source(project: Path) -> None: + governance = project / ".governance" + governance.mkdir() + (governance / "docs-map.json").write_text( + json.dumps({"contract": "docs/missing-contract.md"}), encoding="utf-8" + ) + result = run_audit(project, "artifacts") + assert result.returncode == 1 + assert "Mapped documentation source does not exist" in result.stdout + + +def test_role_map_does_not_follow_a_mapping_symlinked_outside_project( + project: Path, +) -> None: + governance = project / ".governance" + governance.mkdir() + outside = project.parent / "outside-map.json" + outside.write_text('{"context": "private-context.md"}\n', encoding="utf-8") + try: + (governance / "docs-map.json").symlink_to(outside) + result = run_audit(project, "context") + assert result.returncode == 1 + assert "repository-contained regular file" in result.stdout + assert "private-context" not in result.stdout + finally: + outside.unlink(missing_ok=True) + + +def test_default_role_does_not_follow_a_symlink_outside_project(project: Path) -> None: + outside = project.parent / "outside-context.md" + outside.write_text("private context\n", encoding="utf-8") + try: + (project / "CONTEXT.md").symlink_to(outside) + result = run_audit(project, "context") + assert result.returncode == 1 + assert "Documentation role resolves outside the project root" in result.stdout + assert "private context" not in result.stdout + finally: + outside.unlink(missing_ok=True) + + +def test_audit_does_not_modify_repository_files(project: Path) -> None: + (project / "guide.md").write_text("[missing](docs/missing.md)\n", encoding="utf-8") + before = { + path.relative_to(project): path.read_bytes() + for path in project.rglob("*") + if path.is_file() + } + + result = run_audit(project, "full") + + after = { + path.relative_to(project): path.read_bytes() + for path in project.rglob("*") + if path.is_file() + } + assert result.returncode == 1 + assert after == before