fix(skills): harden docs governance audit

This commit is contained in:
Seekers2001
2026-09-03 12:33:52 +08:00
parent 1ea94397b5
commit 06e67e3a2c
9 changed files with 552 additions and 283 deletions
+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
+2 -2
View File
@@ -1,6 +1,6 @@
# Everything Claude Code (ECC) — Agent Talimatları
Bu, yazılım geliştirme için 68 özel agent, 286 skill, 94 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**.
Bu, yazılım geliştirme için 68 özel agent, 287 skill, 94 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**.
**Sürüm:** 2.2.1
@@ -142,7 +142,7 @@ Başarısızlık sorunlarını giderin: test izolasyonunu kontrol edin → mockl
```
agents/ — 68 özel subagent
skills/ — 286 iş akışı skillleri ve alan bilgisi
skills/ — 287 iş akışı skillleri ve alan bilgisi
commands/ — 94 slash command
hooks/ — Tetikleyici tabanlı otomasyonlar
rules/ — Her zaman uyulması gereken kurallar (ortak + dile özel)
+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.
+64 -139
View File
@@ -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
+2 -1
View File
@@ -15,6 +15,7 @@ const MIRRORED_FILES = [
'SKILL.md',
'references/artifact-role-contract.md',
'scripts/audit-docs.py',
'scripts/markdown_links.py',
];
const ROUTED_SKILLS = [
'ai-regression-testing',
@@ -79,4 +80,4 @@ test('opt-in install includes every routed skill owner', () => {
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}\n`);
process.exit(failed === 0 ? 0 : 1);
process.exitCode = failed === 0 ? 0 : 1;
+104
View File
@@ -249,6 +249,22 @@ def test_artifact_scope_checks_angle_wrapped_destination_with_title(
assert "Broken Markdown link: 'index.md' -> 'guide.md'" in result.stdout
@pytest.mark.parametrize(
"usage",
("[guide][docs]", "[docs][]", "[docs]"),
)
def test_artifact_scope_checks_reference_style_markdown_links(
project: Path, usage: str
) -> None:
(project / "index.md").write_text(
f"{usage}\n\n[docs]: missing-guide.md\n", encoding="utf-8"
)
result = run_audit(project, "artifacts")
assert result.returncode == 1
assert "Broken Markdown link" in result.stdout
assert "missing-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)
@@ -274,6 +290,19 @@ def test_adr_scope_accepts_indexed_decision(project: Path) -> None:
assert result.returncode == 0, result.stdout
def test_adr_scope_accepts_documented_bold_status_metadata(project: Path) -> None:
adr_dir = project / "docs" / "adr"
adr_dir.mkdir(parents=True)
(adr_dir / "README.md").write_text(
"[decision](0001-storage.md)\n", encoding="utf-8"
)
(adr_dir / "0001-storage.md").write_text(
"# ADR-0001\n\n**Status**: 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)
@@ -390,6 +419,15 @@ def test_artifact_scope_rejects_unregistered_test_id_when_tests_ledger_exists(
assert "TEST-IDs are missing from the test registry" in result.stdout
def test_artifact_scope_ignores_lowercase_test_hyphenated_prose(
project: Path,
) -> None:
(project / "TESTS.md").write_text("# TESTS\n\nTEST-ORDER-001\n", encoding="utf-8")
(project / "guide.md").write_text("Use a test-driven workflow.\n", encoding="utf-8")
result = run_audit(project, "artifacts")
assert result.returncode == 0, 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")
@@ -420,6 +458,72 @@ def test_log_move_to_archive_preserves_append_only_history(project: Path) -> Non
)
def test_spine_scope_detects_recreated_root_level_deletion_zone_file(
project: Path,
) -> None:
(project / "PROJECT_STATUS.md").write_text(
"# Status\n\n## Deletion Zone\n\n- `legacy_parser.py`\n", encoding="utf-8"
)
(project / "legacy_parser.py").write_text("# recreated\n", encoding="utf-8")
result = run_audit(project, "spine")
assert result.returncode == 1
assert "Deletion-zone target has been recreated" in result.stdout
@pytest.mark.parametrize(
"value",
(
"ftp://example.com/legacy.py",
"mailto:legacy@example.com",
"urn:legacy:parser",
pytest.param(
r"..\legacy.py",
marks=pytest.mark.skipif(
sys.platform.startswith("win"),
reason="the Windows path would escape the temporary project",
),
),
),
)
def test_spine_scope_ignores_unsafe_deletion_zone_values(
project: Path, value: str
) -> None:
candidate = project / value
candidate.parent.mkdir(parents=True, exist_ok=True)
candidate.write_text("# unrelated\n", encoding="utf-8")
(project / "PROJECT_STATUS.md").write_text(
f"# Status\n\n## Deletion Zone\n\n- `{value}`\n", encoding="utf-8"
)
result = run_audit(project, "spine")
assert result.returncode == 0, result.stdout
def test_spine_scope_handles_missing_git_without_traceback(project: Path) -> None:
(project / "PROJECT_LOG.md").write_text(
"# LOG\n\n## [2026-01-01] init | one\n", encoding="utf-8"
)
result = subprocess.run(
[sys.executable, str(SCRIPT), "--root", str(project), "--scope", "spine"],
text=True,
capture_output=True,
check=False,
env={"PATH": ""},
)
assert result.returncode == 0, result.stdout
assert "Git history is unavailable" in result.stdout
assert "Traceback" not in result.stderr
def test_spine_scope_warns_when_root_has_no_git_head(project: Path) -> None:
(project / "PROJECT_LOG.md").write_text(
"# LOG\n\n## [2026-01-01] init | one\n", encoding="utf-8"
)
result = run_audit(project, "spine")
assert result.returncode == 0, result.stdout
assert "Git history is unavailable" in result.stdout
assert "append-only when combined" not in result.stdout
def test_log_duplicate_removal_is_detected(project: Path) -> None:
configure_git(project)
event = "## [2026-01-01] fix | same event\n"