diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a5be53..cca0463 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Check docs + run: python3 scripts/check-docs.py . - name: Set up JDK 25 uses: actions/setup-java@v4 with: diff --git a/.gitignore b/.gitignore index 019358a..c98e3b5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ out/ # 本地 agent 工具目录(R07) .opencode/ .zcode/ +.qoder/ # --- 系统 --- .DS_Store diff --git a/scripts/check-docs.py b/scripts/check-docs.py index df3ec4d..fe695ec 100755 --- a/scripts/check-docs.py +++ b/scripts/check-docs.py @@ -4,9 +4,12 @@ 校验五件事: ① docs 顶层 Markdown 固定为六个文件; ② 稳定 ID 定义唯一性与语法:每个 ID 恰好定义一次,且全仓引用均有定义; - ③ 旧文件名、章节号引用与已闭合 G 标记零命中(docs/legacy/ 外); + ③ 旧文件名、章节号引用、已闭合 G 标记与字母后缀编号零命中(docs/legacy/ 外); ④ 仓库内 Markdown 链接有效。 ⑤ 给出 Git 基线时,持久 ID 集合与活跃 G 集合保持不变。 + ⑥ `application.yml` 的 `msgx.*` 叶子键逐个登记在 reference.md:`msgx.*` 是逐键登记的参数族, + 光看文档查不出「代码有键、注册表没登记」的漂移;`mailbox.*`、`datasources.*`、`kafka.*` + 按 reference.md 的约定成组登记,不做逐键检查。 用法:scripts/check-docs.py [仓库根目录] [Git 基线] """ @@ -56,6 +59,34 @@ USE_PATTERNS = { FIRST_COL = re.compile(r"^\|\s*`?([^|`]+?)`?\s*\|") +# 字母后缀编号(编号数字后面直接接小写字母)不在 ID 语法内。裸 `\bINV-\d+\b` 匹配不到它, +# 于是这类 ID 的定义与引用会一起从校验里消失:曾有一批这样的引用只出现在正文、没有定义, +# 校验器却全数漏过。 +SUFFIXED_ID = re.compile(r"\b(?:US|OPS|C|PRE|INV|CLM|Q)-\d+[a-z]\b") + +# application.yml 叶子键:有值(非空、非纯注释)的键才算叶子,父节点不算。 +YML_KEY_LINE = re.compile(r"^(\s*)([A-Za-z0-9_-]+):\s*(.*)$") + + +def yml_leaf_keys(path: Path, prefix: str) -> list[tuple[str, int]]: + """按缩进还原 `application.yml` 的叶子键全路径,只保留指定前缀。""" + keys: list[tuple[str, int]] = [] + stack: list[tuple[int, str]] = [] + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if not line.strip() or line.lstrip().startswith("#"): + continue + m = YML_KEY_LINE.match(line) + if not m: + continue + indent, key, value = len(m.group(1)), m.group(2), m.group(3).strip() + stack = [item for item in stack if item[0] < indent] + stack.append((indent, key)) + if value and not value.startswith("#"): + full = ".".join(item[1] for item in stack) + if full.startswith(prefix): + keys.append((full, lineno)) + return keys + def is_placeholder(kind: str, ident: str) -> bool: if ident in PLACEHOLDER_WORDS: @@ -234,13 +265,28 @@ def main() -> int: if not failures: print(f"OK ID 注册表:{totals} 个定义各一处且位于所属注册表") + # ⑥ 参数键登记:代码里有、注册表里没有的 `msgx.*` 键,是文档查不出的那类漂移 + app_yml = ROOT / "src/main/resources/application.yml" + if app_yml.exists(): + ref_text = (DOCS / "reference.md").read_text(encoding="utf-8") + unregistered = [ + f"src/main/resources/application.yml:{lineno} -> {key}" + for key, lineno in yml_leaf_keys(app_yml, "msgx.") + if key not in ref_text + ] + if unregistered: + failures.append("application.yml 的 msgx.* 参数未登记在 reference.md") + failures += unregistered + else: + print("OK application.yml 的 msgx.* 参数均已登记在 reference.md") + # ③ 旧文件名、章节号引用与已闭合 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 = [], [], [] + stale_hits, section_hits, closed_g_hits, suffixed_hits = [], [], [], [] for f in all_repo_files: try: text = f.read_text(encoding="utf-8") @@ -253,6 +299,9 @@ def main() -> int: 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 "legacy" not in f.parts: + for ident in SUFFIXED_ID.findall(line): + suffixed_hits.append(f"{f.relative_to(ROOT)}:{i} -> {ident}") if stale_hits: failures.append("存在指向已删除文档的文件名引用") failures += stale_hits @@ -268,6 +317,11 @@ def main() -> int: failures += closed_g_hits else: print("OK 已闭合 G 标记零命中") + if suffixed_hits: + failures.append("存在字母后缀编号(ID 不含字母后缀,同一编号只有一条定义)") + failures += suffixed_hits + else: + print("OK 字母后缀编号零命中") # ④ 仓库内 Markdown 链接 link_files = list(DOCS.rglob("*.md")) + [ROOT / "README.md", ROOT / "AGENTS.md"] diff --git a/scripts/check-docs.sh b/scripts/check-docs.sh index 3308cb4..59529f9 100755 --- a/scripts/check-docs.sh +++ b/scripts/check-docs.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# 文档体系机械校验入口:顶层结构、ID 注册表与全仓引用、陈旧引用、活跃 G、Markdown 链接。 +# 文档体系机械校验入口:顶层结构、ID 注册表与全仓引用、application.yml 的 msgx.* 参数登记、 +# 陈旧引用、活跃 G、字母后缀编号、Markdown 链接。 # 用法:scripts/check-docs.sh [Git 基线] (在仓库根目录执行) set -uo pipefail cd "$(dirname "$0")/.."