From 03ca6c39e04aa323e42146c8fa05d2dd41462db2 Mon Sep 17 00:00:00 2001 From: Seekers2001 Date: Fri, 11 Sep 2026 17:31:10 +0800 Subject: [PATCH] fix(governance): validate inline ADR successors --- .../docs-governance/scripts/audit-docs.py | 23 +++++++++++++++++++ skills/docs-governance/scripts/audit-docs.py | 23 +++++++++++++++++++ tests/test_docs_governance_audit.py | 19 ++++++++++++++- 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/.agents/skills/docs-governance/scripts/audit-docs.py b/.agents/skills/docs-governance/scripts/audit-docs.py index 65af06a8d..53f35ee36 100644 --- a/.agents/skills/docs-governance/scripts/audit-docs.py +++ b/.agents/skills/docs-governance/scripts/audit-docs.py @@ -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.*?)(?:\n## |\Z)", text ) diff --git a/skills/docs-governance/scripts/audit-docs.py b/skills/docs-governance/scripts/audit-docs.py index 65af06a8d..53f35ee36 100644 --- a/skills/docs-governance/scripts/audit-docs.py +++ b/skills/docs-governance/scripts/audit-docs.py @@ -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.*?)(?:\n## |\Z)", text ) diff --git a/tests/test_docs_governance_audit.py b/tests/test_docs_governance_audit.py index f3b067ad3..72daa2688 100644 --- a/tests/test_docs_governance_audit.py +++ b/tests/test_docs_governance_audit.py @@ -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)