mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-15 20:55:28 +02:00
fix(skills): harden docs governance audit
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user