fix(skills): harden docs governance audit

This commit is contained in:
Seekers2001 committed 2026-09-03 12:33:52 +08:00
1 parent 1ea94397b5
commit 06e67e3a2c
9 files changed
+552 -283

No files matched your search

+1 -1
View File
@@ -83,7 +83,7 @@ request, invoke that specialized skill without routing through this one.
- “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`.
bundled audit once with `--scope artifacts`, then again with `--scope adr`.
- “Are we finished with the migration?” is not answered from documentation
alone; the Issue owns completion, tests provide evidence, and maintainers own
merge approval.
@@ -10,7 +10,12 @@ import subprocess
import sys
from collections import Counter
from pathlib import Path
from urllib.parse import unquote
from markdown_links import (
markdown_link_targets,
normalize_link_target,
without_fenced_code,
)
DEFAULT_ROLES = {
"constitution": "CLAUDE.md",
@@ -30,16 +35,12 @@ ENTRY_RE = re.compile(
r"^## \[(?P<date>\d{4}-\d{2}-\d{2})\]\s+(?P<type>[^|\n]+?)\s*\|\s*(?P<summary>[^\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,
)
TEST_ID_RE = re.compile(r"\bTEST-[A-Z0-9][A-Z0-9-]*\b")
ADR_TARGET_RE = re.compile(r"\b(?:ADR-)?\d{3,4}-[a-z0-9-]+\.md\b", re.IGNORECASE)
IGNORED_DIRS = {".git", ".governance", ".venv", "node_modules", "vendor", "__pycache__"}
IGNORED_REFERENCE_MARKERS = ("*", "{", "}", "<", ">", "…", "...")
GIT_TIMEOUT_SECONDS = 5
class Report:
@@ -212,122 +213,20 @@ def event_contents(text: str) -> list[str]:
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)]
def run_git(
root: Path, arguments: list[str]
) -> subprocess.CompletedProcess[str] | None:
try:
return subprocess.run(
["git", *arguments],
cwd=root,
text=True,
capture_output=True,
check=False,
timeout=GIT_TIMEOUT_SECONDS,
)
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("//"):
except (OSError, subprocess.SubprocessError):
return None
if EXTERNAL_URI_RE.match(target):
return None
return target
def resolve_within_root(root: Path, base: Path, target: str) -> Path | None:
@@ -374,6 +273,23 @@ def plausible_code_path(value: str) -> bool:
return "/" in value
def plausible_deletion_path(value: str) -> bool:
if any(marker in value for marker in IGNORED_REFERENCE_MARKERS):
return False
if any(character.isspace() for character in value):
return False
if value.startswith(("//", "$", ".governance/")):
return False
if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", value):
return False
path = Path(value.replace("\\", "/"))
return (
value not in {"", ".", ".."}
and not path.is_absolute()
and ".." not in path.parts
)
def check_spine_paths(root: Path, roles: dict[str, str], report: Report) -> None:
broken: list[str] = []
for role in (
@@ -425,7 +341,7 @@ def check_status_resurrection(
resurrected: list[str] = []
if match:
for value in CODE_PATH_RE.findall(match.group("body")):
if plausible_code_path(value):
if plausible_deletion_path(value):
resolved = resolve_within_root(root, root, value)
if resolved is not None and resolved.exists():
resurrected.append(value)
@@ -452,24 +368,26 @@ def combined_history_events(
return active_events, Counter(active_events + event_contents(archive_text))
def previous_history_events(root: Path, roles: dict[str, str]) -> Counter[str]:
def previous_history_events(root: Path, roles: dict[str, str]) -> Counter[str] | None:
head = run_git(root, ["rev-parse", "--verify", "HEAD"])
if head is None or head.returncode != 0:
return None
previous_events: Counter[str] = Counter()
for relative in (roles["history"], roles["history_archive"]):
previous = git_show(root, relative)
if previous is not None:
previous_events.update(event_contents(previous))
command = run_git(root, ["show", f"HEAD:{relative}"])
if command is None:
return None
if command.returncode == 0:
previous_events.update(event_contents(command.stdout))
return previous_events
def check_derived_history_index(root: Path, report: Report) -> None:
tracked = subprocess.run(
["git", "ls-files", "--error-unmatch", ".governance/project-log.sqlite"],
cwd=root,
text=True,
capture_output=True,
check=False,
tracked = run_git(
root,
["ls-files", "--error-unmatch", ".governance/project-log.sqlite"],
)
if tracked.returncode == 0:
if tracked is not None and tracked.returncode == 0:
report.fail(
".governance/project-log.sqlite is a derived index and must not be committed"
)
@@ -487,13 +405,19 @@ def check_log(
if events is None:
return
active_events, current_events = events
missing = previous_history_events(root, roles) - current_events
if missing:
previous_events = previous_history_events(root, roles)
if previous_events is None:
report.warn(
f"{sum(missing.values())} working-tree history events differ from HEAD; confirm intentional correction or redaction"
"Git history is unavailable; skipping the HEAD append-only comparison and tracked derived-index check"
)
else:
report.ok("Active and archived history remain append-only when combined")
missing = previous_events - current_events
if missing:
report.warn(
f"{sum(missing.values())} working-tree history events differ from HEAD; confirm intentional correction or redaction"
)
else:
report.ok("Active and archived history remain append-only when combined")
if len(active_events) > threshold:
report.warn(
f"Active history has {len(active_events)} events, above {threshold}; review before archiving and rebuilding the SQLite index"
@@ -502,12 +426,13 @@ def check_log(
report.ok(
f"Active history has {len(active_events)} events, within the {threshold} threshold"
)
check_derived_history_index(root, report)
if previous_events is not None:
check_derived_history_index(root, report)
def parse_adr_status(text: str) -> str | None:
patterns = (
r"(?im)^[-*]?\s*(?:status|\u72b6\u6001)\s*[:\uFF1A]\s*`?([a-z]+)`?\s*$",
r"(?im)^(?:[-*]\s+)?(?:\*\*(?:status|\u72b6\u6001)\*\*|(?: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:
@@ -0,0 +1,157 @@
"""Small Markdown link parser used by the documentation-governance audit."""
from __future__ import annotations
import re
from urllib.parse import unquote
INLINE_LINK_START_RE = re.compile(r"\[[^\]\n]*\]\(")
REFERENCE_DEFINITION_RE = re.compile(
r"""(?mx)
^\ {0,3}\[(?P<label>[^\]\n]+)\]:[\t\ ]*
(?P<target><[^>\n]+>|[^\t\ \n]+)
(?:[\t\ ]+(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\([^()]*\)))?
[\t\ ]*$
"""
)
REFERENCE_USAGE_RE = re.compile(
r"(?<![!\\])\[(?P<text>[^\]\n]+)\](?:\[(?P<label>[^\]\n]*)\])?"
)
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,
)
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 inline_link_targets(text: str) -> list[str]:
targets: list[str] = []
for match in INLINE_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_reference_label(label: str) -> str:
unescaped = re.sub(r"\\([\\\[\]])", r"\1", label)
return re.sub(r"\s+", " ", unescaped.strip()).casefold()
def reference_link_targets(text: str) -> list[str]:
definitions = {
normalize_reference_label(match.group("label")): match.group("target")
for match in REFERENCE_DEFINITION_RE.finditer(text)
}
targets: list[str] = []
for match in REFERENCE_USAGE_RE.finditer(text):
following = text[match.end() : match.end() + 1]
if following in {"(", ":"}:
continue
label = match.group("label")
key = normalize_reference_label(label or match.group("text"))
if key in definitions:
targets.append(definitions[key])
return targets
def markdown_link_targets(text: str) -> list[str]:
text = without_inline_code(without_fenced_code(text))
return inline_link_targets(text) + reference_link_targets(text)
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