fix(governance): validate inline ADR successors

This commit is contained in:
Seekers2001
2026-09-11 17:31:10 +08:00
parent 1225c714e4
commit 03ca6c39e0
3 changed files with 64 additions and 1 deletions
@@ -38,6 +38,9 @@ ENTRY_RE = re.compile(
CODE_PATH_RE = re.compile(r"`([^`\n]+)`")
TEST_ID_RE = re.compile(r"\bTEST-[A-Z0-9][A-Z0-9-]*\b")
ADR_TARGET_RE = re.compile(r"\b(?:ADR-)?\d{3,4}-[a-z0-9-]+\.md\b", re.IGNORECASE)
ADR_INLINE_SUCCESSOR_RE = re.compile(
r"(?im)^(?:[-*]\s+)?(?:\*\*(?:status|状态)\*\*|(?:status|状态))\s*[::]\s*`?superseded`?\s+by\s+(ADR-\d{3,4})\s*$"
)
IGNORED_DIRS = {".git", ".governance", ".venv", "node_modules", "vendor", "__pycache__"}
IGNORED_REFERENCE_MARKERS = ("*", "{", "}", "<", ">", "…", "...")
GIT_TIMEOUT_SECONDS = 5
@@ -442,6 +445,16 @@ def parse_adr_status(text: str) -> str | None:
return None
def inline_adr_successor(text: str) -> str | None:
match = ADR_INLINE_SUCCESSOR_RE.search(text)
return match.group(1).upper() if match else None
def adr_identifier(text: str) -> str | None:
match = re.search(r"(?im)^#\s+(ADR-\d{3,4})\b", text)
return match.group(1).upper() if match else None
def indexed_adr_paths(
root: Path, index: Path, index_text: str, report: Report
) -> set[Path]:
@@ -520,6 +533,16 @@ def check_adr_lifecycle(
"ADR status is unsupported: "
f"{diagnostic(path.relative_to(root))} -> {status}"
)
successor = inline_adr_successor(text)
if successor and successor not in {
identifier
for source_text in sources.values()
if (identifier := adr_identifier(source_text)) is not None
}:
report.fail(
"ADR inline successor does not exist: "
f"{diagnostic(path.relative_to(root))} -> {successor}"
)
supersedes = re.search(
r"(?ims)^##\s+Supersedes\s*\n(?P<body>.*?)(?:\n## |\Z)", text
)
@@ -38,6 +38,9 @@ ENTRY_RE = re.compile(
CODE_PATH_RE = re.compile(r"`([^`\n]+)`")
TEST_ID_RE = re.compile(r"\bTEST-[A-Z0-9][A-Z0-9-]*\b")
ADR_TARGET_RE = re.compile(r"\b(?:ADR-)?\d{3,4}-[a-z0-9-]+\.md\b", re.IGNORECASE)
ADR_INLINE_SUCCESSOR_RE = re.compile(
r"(?im)^(?:[-*]\s+)?(?:\*\*(?:status|状态)\*\*|(?:status|状态))\s*[::]\s*`?superseded`?\s+by\s+(ADR-\d{3,4})\s*$"
)
IGNORED_DIRS = {".git", ".governance", ".venv", "node_modules", "vendor", "__pycache__"}
IGNORED_REFERENCE_MARKERS = ("*", "{", "}", "<", ">", "…", "...")
GIT_TIMEOUT_SECONDS = 5
@@ -442,6 +445,16 @@ def parse_adr_status(text: str) -> str | None:
return None
def inline_adr_successor(text: str) -> str | None:
match = ADR_INLINE_SUCCESSOR_RE.search(text)
return match.group(1).upper() if match else None
def adr_identifier(text: str) -> str | None:
match = re.search(r"(?im)^#\s+(ADR-\d{3,4})\b", text)
return match.group(1).upper() if match else None
def indexed_adr_paths(
root: Path, index: Path, index_text: str, report: Report
) -> set[Path]:
@@ -520,6 +533,16 @@ def check_adr_lifecycle(
"ADR status is unsupported: "
f"{diagnostic(path.relative_to(root))} -> {status}"
)
successor = inline_adr_successor(text)
if successor and successor not in {
identifier
for source_text in sources.values()
if (identifier := adr_identifier(source_text)) is not None
}:
report.fail(
"ADR inline successor does not exist: "
f"{diagnostic(path.relative_to(root))} -> {successor}"
)
supersedes = re.search(
r"(?ims)^##\s+Supersedes\s*\n(?P<body>.*?)(?:\n## |\Z)", text
)
+18 -1
View File
@@ -374,15 +374,32 @@ def test_adr_scope_accepts_superseded_status_with_successor(project: Path) -> No
adr_dir = project / "docs" / "adr"
adr_dir.mkdir(parents=True)
(adr_dir / "README.md").write_text(
"[decision](0001-storage.md)\n", encoding="utf-8"
"[old](0001-storage.md)\n[new](0002-storage.md)\n", encoding="utf-8"
)
(adr_dir / "0001-storage.md").write_text(
"# ADR-0001\n\n**Status**: superseded by ADR-0002\n", encoding="utf-8"
)
(adr_dir / "0002-storage.md").write_text(
"# ADR-0002\n\n**Status**: accepted\n", encoding="utf-8"
)
result = run_audit(project, "adr")
assert result.returncode == 0, result.stdout
def test_adr_scope_rejects_missing_inline_superseded_successor(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**: superseded by ADR-9999\n", encoding="utf-8"
)
result = run_audit(project, "adr")
assert result.returncode == 1
assert "ADR inline successor does not exist" in result.stdout
def test_adr_scope_audits_common_adr_prefixed_filenames(project: Path) -> None:
adr_dir = project / "docs" / "adr"
adr_dir.mkdir(parents=True)