docs(acm2-74): consolidate design documentation
This commit is contained in:
Executable
+327
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env python3
|
||||
"""文档体系机械校验(docs/README.md「维护清单」)。
|
||||
|
||||
校验五件事:
|
||||
① docs 顶层 Markdown 固定为六个文件;
|
||||
② 稳定 ID 定义唯一性与语法:每个 ID 恰好定义一次,且全仓引用均有定义;
|
||||
③ 旧文件名、章节号引用与已闭合 G 标记零命中(docs/legacy/ 外);
|
||||
④ 仓库内 Markdown 链接有效。
|
||||
⑤ 给出 Git 基线时,持久 ID 集合与活跃 G 集合保持不变。
|
||||
|
||||
用法:scripts/check-docs.py [仓库根目录] [Git 基线]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
|
||||
BASELINE = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
|
||||
DOCS = ROOT / "docs"
|
||||
TOP_FILES = ["README.md", "requirements.md", "architecture.md", "specification.md",
|
||||
"implementation.md", "reference.md"]
|
||||
TEXT_SUFFIXES = {".md", ".kt", ".kts", ".yml", ".yaml", ".sql", ".py", ".sh"}
|
||||
SKIP_DIRS = {
|
||||
".git", ".gradle", ".gradle-home", ".gradletmp", ".idea", ".kotlin",
|
||||
".opencode", ".qoder", ".zcode", "build", "node_modules", "out",
|
||||
}
|
||||
OWNER = {
|
||||
"US": "requirements.md", "OPS": "requirements.md", "D": "architecture.md",
|
||||
"C": "specification.md", "PRE": "specification.md", "INV": "specification.md",
|
||||
"CLM": "specification.md", "Q": "specification.md", "G": "specification.md",
|
||||
"PARAM": "reference.md",
|
||||
}
|
||||
# 文档中的 ID 族示例不是真实定义。
|
||||
PLACEHOLDER_WORDS = {"G-NAME"}
|
||||
|
||||
PATTERNS = {
|
||||
"US": re.compile(r"\bUS-\d+\b"),
|
||||
"OPS": re.compile(r"\bOPS-\d+\b"),
|
||||
"D": re.compile(r"(?<![A-Za-z0-9-])D[1-9]\d*(?![0-9])"),
|
||||
"C": re.compile(r"\bC-\d+\b"),
|
||||
"PRE": re.compile(r"\bPRE-\d+\b"),
|
||||
"INV": re.compile(r"\bINV-\d+\b"),
|
||||
"CLM": re.compile(r"\bCLM-\d+\b"),
|
||||
"Q": re.compile(r"\bQ\d+\b"),
|
||||
"G": re.compile(r"\bG-[A-Z][A-Z0-9-]*"),
|
||||
"PARAM": re.compile(r"\b(?:msgx|mailbox|datasources|kafka)\.[A-Za-z0-9._-]+"),
|
||||
}
|
||||
USE_PATTERNS = {
|
||||
**PATTERNS,
|
||||
"PARAM": re.compile(r"(?<=PARAM:)(?:msgx|mailbox|datasources|kafka)\.[A-Za-z0-9._-]+"),
|
||||
}
|
||||
|
||||
FIRST_COL = re.compile(r"^\|\s*`?([^|`]+?)`?\s*\|")
|
||||
|
||||
|
||||
def is_placeholder(kind: str, ident: str) -> bool:
|
||||
if ident in PLACEHOLDER_WORDS:
|
||||
return True
|
||||
# 通配/残缺键(如 `msgx.pipeline.*` 里被截出的 `msgx.pipeline.`)不算 ID
|
||||
return ident.endswith(".") or "*" in ident
|
||||
|
||||
|
||||
HEADING = re.compile(r"^(#{2,3})\s+(.+)$")
|
||||
|
||||
|
||||
def section_of(lines: list[str]) -> list[str]:
|
||||
"""为每一行给出其所属小节的标题文本(二级取全名,三级取 `二级 / 三级`)。"""
|
||||
out, h2, h3 = [], "", ""
|
||||
for line in lines:
|
||||
m = HEADING.match(line)
|
||||
if m:
|
||||
if len(m.group(1)) == 2:
|
||||
h2, h3 = m.group(2).strip(), ""
|
||||
else:
|
||||
h3 = m.group(2).strip()
|
||||
out.append(f"{h2} / {h3}" if h3 else h2)
|
||||
return out
|
||||
|
||||
|
||||
def first_col(line: str) -> str | None:
|
||||
"""取表格首列原始内容(不含两侧管道与空白);非表格行返回 None。"""
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("|"):
|
||||
return None
|
||||
cell = stripped[1:].split("|", 1)[0]
|
||||
return cell.strip()
|
||||
|
||||
|
||||
def repo_text_files(*, include_legacy: bool = False) -> list[Path]:
|
||||
"""返回仓库内需受文档引用纪律约束的文本文件,排除生成物。"""
|
||||
files: list[Path] = []
|
||||
for path in ROOT.rglob("*"):
|
||||
if not path.is_file() or any(part in SKIP_DIRS for part in path.parts):
|
||||
continue
|
||||
if (not include_legacy and "legacy" in path.parts) or path.suffix not in TEXT_SUFFIXES:
|
||||
continue
|
||||
files.append(path)
|
||||
return sorted(files)
|
||||
|
||||
|
||||
def git_top_docs(ref: str) -> dict[str, str]:
|
||||
"""读取某 Git 基线的 docs 顶层 Markdown,不读取工作树或 legacy。"""
|
||||
listed = subprocess.run(
|
||||
["git", "ls-tree", "-r", "--name-only", ref, "--", "docs"],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.splitlines()
|
||||
docs: dict[str, str] = {}
|
||||
for name in listed:
|
||||
path = Path(name)
|
||||
if path.parent != Path("docs") or path.suffix != ".md":
|
||||
continue
|
||||
docs[name] = subprocess.run(
|
||||
["git", "show", f"{ref}:{name}"],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
return docs
|
||||
|
||||
|
||||
def ids_in(texts: list[str], kind: str) -> set[str]:
|
||||
found: set[str] = set()
|
||||
for text in texts:
|
||||
for ident in PATTERNS[kind].findall(text):
|
||||
ident = ident.rstrip("`")
|
||||
if not is_placeholder(kind, ident):
|
||||
found.add(ident)
|
||||
return found
|
||||
|
||||
|
||||
def active_g_in(texts: list[str]) -> set[str]:
|
||||
"""从基线注册表首列提取未划销、未标已闭合的 G。"""
|
||||
found: set[str] = set()
|
||||
for text in texts:
|
||||
for line in text.splitlines():
|
||||
if "~~" in line or re.search(r"已(?:闭合|关闭)", line):
|
||||
continue
|
||||
for ident in PATTERNS["G"].findall(line):
|
||||
if is_definition("G", ident, line):
|
||||
found.add(ident)
|
||||
return found
|
||||
|
||||
|
||||
def is_definition(kind: str, ident: str, line: str) -> bool:
|
||||
"""定义语法见 docs/README.md「ID 定义语法与引用纪律」。
|
||||
|
||||
注册表的表格首列在「单个 ID」时构成定义;成组登记与同行多 ID 均视为引用。
|
||||
"""
|
||||
if kind == "US":
|
||||
return re.match(r"^###\s+" + re.escape(ident) + r"(\D|$)", line) is not None
|
||||
if kind in ("C", "INV"):
|
||||
return re.match(r"^-\s+\*\*" + re.escape(ident) + r"\*\*", line) is not None
|
||||
cell = first_col(line)
|
||||
if cell is None:
|
||||
return False
|
||||
# 严格匹配:`ID` 引用行(如 `INV-20` / `CLM-3`)不算定义
|
||||
return cell in (ident, f"`{ident}`")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failures: list[str] = []
|
||||
|
||||
# ① docs 顶层固定为六个 Markdown 文件
|
||||
actual_top = {path.name for path in DOCS.glob("*.md")}
|
||||
expected_top = set(TOP_FILES)
|
||||
if actual_top != expected_top:
|
||||
failures.append(
|
||||
"docs 顶层 Markdown 不等于固定六文件:"
|
||||
f"缺少={sorted(expected_top - actual_top)},多出={sorted(actual_top - expected_top)}"
|
||||
)
|
||||
else:
|
||||
print("OK docs 顶层固定为六个 Markdown 文件")
|
||||
|
||||
repo_files = repo_text_files()
|
||||
all_repo_files = repo_text_files(include_legacy=True)
|
||||
|
||||
# ② ID 注册表:每个被引用的 ID 必须恰好有一处定义,且位于自己的注册表
|
||||
# 触发检查的范围是「定义行」(加粗定义行 / US 标题 / 注册表首列),
|
||||
# 与 docs/README.md「ID 定义语法与引用纪律」一致。
|
||||
defs: dict[tuple[str, str], list[str]] = {}
|
||||
for doc in sorted(DOCS.glob("*.md")):
|
||||
lines = doc.read_text(encoding="utf-8").splitlines()
|
||||
sections = section_of(lines)
|
||||
for i, line in enumerate(lines, 1):
|
||||
for kind, pattern in PATTERNS.items():
|
||||
for ident in pattern.findall(line):
|
||||
ident = ident.rstrip("`")
|
||||
if is_placeholder(kind, ident) or not is_definition(kind, ident, line):
|
||||
continue
|
||||
defs.setdefault((kind, ident), []).append(f"{doc.name}:{i}:{sections[i - 1]}")
|
||||
|
||||
# 参数与指标同表登记、语义不同,二者都算已登记
|
||||
REGISTRY = { # ID → (所属文件, 允许的定义小节)
|
||||
"US": ("requirements.md", ["用户故事"]),
|
||||
"OPS": ("requirements.md", ["运行与切流验收", "需求覆盖与依赖"]),
|
||||
"D": ("architecture.md", ["关键决策"]),
|
||||
"C": ("specification.md", ["契约"]),
|
||||
"PRE": ("specification.md", ["前提"]),
|
||||
"INV": ("specification.md", ["不变量"]),
|
||||
"CLM": ("specification.md", ["声明边界"]),
|
||||
"Q": ("specification.md", ["待确认事项台账"]),
|
||||
"G": ("specification.md", ["当前已知偏差"]),
|
||||
"PARAM": ("reference.md", ["参数注册表", "指标与健康"]),
|
||||
}
|
||||
totals = 0
|
||||
for (kind, ident), where in sorted(defs.items()):
|
||||
totals += 1
|
||||
owner, sections = REGISTRY[kind]
|
||||
ok = any(w.startswith(f"{owner}:") and any(sec in w for sec in sections) for w in where)
|
||||
if len(where) > 1 and len(set(where)) > 1:
|
||||
failures.append(f"{kind} {ident} 定义 {len(where)} 次({', '.join(where)})")
|
||||
elif not ok:
|
||||
failures.append(f"{kind} {ident} 定义不在 docs/{owner}{sections}:{where[0]}")
|
||||
# 被引用但无定义的 ID(`defs` 已由上面的注册表检查确保位置与语法正确)
|
||||
for kind, pattern in USE_PATTERNS.items():
|
||||
used: set[str] = set()
|
||||
for doc in repo_files:
|
||||
for line in doc.read_text(encoding="utf-8").splitlines():
|
||||
for ident in pattern.findall(line):
|
||||
ident = ident.rstrip("`")
|
||||
if not is_placeholder(kind, ident):
|
||||
used.add(ident)
|
||||
for ident in sorted(used):
|
||||
if (kind, ident) not in defs:
|
||||
failures.append(f"{kind} {ident} 已引用但无定义行")
|
||||
if not failures:
|
||||
print(f"OK ID 注册表:{totals} 个定义各一处且位于所属注册表")
|
||||
|
||||
# ③ 旧文件名、章节号引用与已闭合 G 标记零命中(legacy 外)
|
||||
stale_name = re.compile(
|
||||
r"(design|invariants|contracts|flight-state|user-stories|spec-boundary-closure"
|
||||
r"|message-lifecycle|runbooks)\.md")
|
||||
section_ref = re.compile(r"(?:§\s*\d|(?:第\s*)?\d+(?:\.\d+)*\s*节)")
|
||||
closed_g = re.compile(r"G-[A-Z][A-Z0-9-]*.*(?:✓|已闭合|已关闭)")
|
||||
stale_hits, section_hits, closed_g_hits = [], [], []
|
||||
for f in all_repo_files:
|
||||
try:
|
||||
text = f.read_text(encoding="utf-8")
|
||||
except (UnicodeDecodeError, OSError):
|
||||
continue
|
||||
for i, line in enumerate(text.splitlines(), 1):
|
||||
if stale_name.search(line):
|
||||
stale_hits.append(f"{f.relative_to(ROOT)}:{i} -> {stale_name.search(line).group(0)}")
|
||||
if "legacy" not in f.parts and section_ref.search(line):
|
||||
section_hits.append(f"{f.relative_to(ROOT)}:{i}")
|
||||
if "legacy" not in f.parts and closed_g.search(line):
|
||||
closed_g_hits.append(f"{f.relative_to(ROOT)}:{i}")
|
||||
if stale_hits:
|
||||
failures.append("存在指向已删除文档的文件名引用")
|
||||
failures += stale_hits
|
||||
else:
|
||||
print("OK 旧文件名零命中")
|
||||
if section_hits:
|
||||
failures.append("存在章节号引用(应改为稳定 ID 或「文件名 + 小节名」)")
|
||||
failures += section_hits
|
||||
else:
|
||||
print("OK 章节号引用零命中")
|
||||
if closed_g_hits:
|
||||
failures.append("存在已闭合 G 标记(应删除定义与全仓引用)")
|
||||
failures += closed_g_hits
|
||||
else:
|
||||
print("OK 已闭合 G 标记零命中")
|
||||
|
||||
# ④ 仓库内 Markdown 链接
|
||||
link_files = list(DOCS.rglob("*.md")) + [ROOT / "README.md", ROOT / "AGENTS.md"]
|
||||
count, broken = 0, []
|
||||
for f in link_files:
|
||||
if not f.exists():
|
||||
continue
|
||||
for i, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1):
|
||||
for m in re.finditer(r"\[[^\]]*\]\(([^)]+)\)", line):
|
||||
target = m.group(1).split("#")[0].strip()
|
||||
if not target or target.startswith(("http://", "https://", "mailto:")):
|
||||
continue
|
||||
count += 1
|
||||
if not (f.parent / target).resolve().exists():
|
||||
broken.append(f"{f.relative_to(ROOT)}:{i} -> {target}")
|
||||
if broken:
|
||||
failures.append("存在失效链接")
|
||||
failures += broken
|
||||
else:
|
||||
print(f"OK 仓库内 Markdown 链接({count} 个)")
|
||||
|
||||
# ⑤ 文档合并/重命名时,与给定基线比较持久 ID 与活跃 G 集合
|
||||
if BASELINE:
|
||||
try:
|
||||
baseline_texts = list(git_top_docs(BASELINE).values())
|
||||
except subprocess.CalledProcessError as exc:
|
||||
failures.append(f"无法读取 Git 基线 {BASELINE}: {exc.stderr.strip()}")
|
||||
else:
|
||||
for kind in ("US", "OPS", "D", "C", "PRE", "INV", "CLM", "Q", "PARAM"):
|
||||
before = ids_in(baseline_texts, kind)
|
||||
after = {ident for defined_kind, ident in defs if defined_kind == kind}
|
||||
if before != after:
|
||||
failures.append(
|
||||
f"{kind} 集合相对 {BASELINE} 变化:"
|
||||
f"删除={sorted(before - after)},新增={sorted(after - before)}"
|
||||
)
|
||||
before_g = active_g_in(baseline_texts)
|
||||
after_g = {ident for defined_kind, ident in defs if defined_kind == "G"}
|
||||
if before_g != after_g:
|
||||
failures.append(
|
||||
f"活跃 G 集合相对 {BASELINE} 变化:"
|
||||
f"删除={sorted(before_g - after_g)},新增={sorted(after_g - before_g)}"
|
||||
)
|
||||
if not any("相对" in failure for failure in failures):
|
||||
print(f"OK 持久 ID 与活跃 G 集合相对 {BASELINE} 无变化")
|
||||
|
||||
if failures:
|
||||
for item in failures:
|
||||
print(f"FAIL {item}")
|
||||
print("存在失败项")
|
||||
return 1
|
||||
print("全部通过")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# 文档体系机械校验入口:顶层结构、ID 注册表与全仓引用、陈旧引用、活跃 G、Markdown 链接。
|
||||
# 用法:scripts/check-docs.sh [Git 基线] (在仓库根目录执行)
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
exec python3 scripts/check-docs.py . "$@"
|
||||
Reference in New Issue
Block a user