|
| 1 | +#!/usr/bin/env bash |
| 2 | +# check_docs_structure.sh — the rules of .agents/skills/mcpp-docs-style that are |
| 3 | +# about WHERE a document lives rather than how its prose reads. |
| 4 | +# |
| 5 | +# check_docs_style.sh covers the register. This covers the architecture: |
| 6 | +# |
| 7 | +# 1. no user chapter cites a design record |
| 8 | +# 2. a specification cites one only in its metadata table |
| 9 | +# 3. every docs/*.md path named outside .agents/ resolves |
| 10 | +# 4. every specification is listed in all three indexes |
| 11 | +# 5. every specification has a metadata table and a change record |
| 12 | +# 6. no emoji under docs/ or in a top-level README |
| 13 | +# 7. the generated design-record index is current |
| 14 | +# 8. a new design record declares its subject and status |
| 15 | +# 9. every relative link in docs/ and examples/ resolves |
| 16 | +# 10. a translation carries the same tables and code blocks |
| 17 | +# 11. every chapter states its reader, its question and its exclusions |
| 18 | +# 12. a citation naming a section lands in the chapter that contains it |
| 19 | +# 13. every table the manifest reference documents is in the lookup index |
| 20 | +# |
| 21 | +# What it deliberately does NOT check: whether a chapter documents what is |
| 22 | +# implemented, whether an assertion's strength matches its evidence, or whether |
| 23 | +# a surface's coverage has a denominator. Those need a reader, and they are the |
| 24 | +# three most important rules in the skill. |
| 25 | +# |
| 26 | +# Usage: bash .github/tools/check_docs_structure.sh |
| 27 | +set -uo pipefail |
| 28 | +cd "$(dirname "$0")/../.." || exit 1 |
| 29 | + |
| 30 | +fail=0 |
| 31 | +bad() { echo "FAIL: $*"; fail=1; } |
| 32 | + |
| 33 | +# ── 1. no user chapter cites a design record ────────────────────────────── |
| 34 | +# |
| 35 | +# The two READMEs are exempt BY DECISION, not by accident: they are where the |
| 36 | +# three-tree architecture is published, so they name `.agents/docs/` in order to |
| 37 | +# say what it holds and that a chapter must not send a reader there. Every other |
| 38 | +# file under docs/ that names it has delegated a question it should have |
| 39 | +# answered -- see "引用方向是规则" in the skill. |
| 40 | +ARCHITECTURE_PAGES="docs/README.md docs/zh/README.md" |
| 41 | +for f in docs/*.md docs/zh/*.md; do |
| 42 | + case " $ARCHITECTURE_PAGES " in *" $f "*) continue ;; esac |
| 43 | + if grep -n '\.agents/' "$f" >/dev/null 2>&1; then |
| 44 | + while IFS= read -r hit; do |
| 45 | + bad "$f: a user chapter cites a design record: ${hit}" |
| 46 | + done < <(grep -n '\.agents/' "$f") |
| 47 | + fi |
| 48 | +done |
| 49 | + |
| 50 | +# ── 2. a specification cites a design record only in its metadata table ─── |
| 51 | +# |
| 52 | +# Provenance belongs in the metadata row. A citation in the body is the same |
| 53 | +# delegation rule 1 refuses, one tree over. |
| 54 | +for f in docs/specs/*.md; do |
| 55 | + [ "$(basename "$f")" = "README.md" ] && continue |
| 56 | + # The metadata table is the leading block: everything before the first `##`. |
| 57 | + body_start=$(grep -n '^## ' "$f" | head -1 | cut -d: -f1) |
| 58 | + [ -z "$body_start" ] && body_start=1 |
| 59 | + if awk -v s="$body_start" 'NR >= s && /\.agents\//' "$f" | grep -q .; then |
| 60 | + bad "$f: cites a design record outside its metadata table" |
| 61 | + fi |
| 62 | +done |
| 63 | + |
| 64 | +# ── 3. every docs/*.md path named outside .agents/ resolves ─────────────── |
| 65 | +# |
| 66 | +# A comment in src/ naming a chapter is a citation. Six of them named chapters |
| 67 | +# that had not existed since an earlier numbering, and nothing reported it. |
| 68 | +while IFS= read -r p; do |
| 69 | + [ -f "$p" ] || bad "a document names \`$p\`, which does not exist" |
| 70 | +done < <(git grep -ohE 'docs/[0-9]{2}-[a-z0-9-]+\.md' -- ':!.agents' | sort -u) |
| 71 | + |
| 72 | +# ── 4. every specification is listed in all three indexes ──────────────── |
| 73 | +for f in docs/specs/*.md; do |
| 74 | + base="$(basename "$f")" |
| 75 | + [ "$base" = "README.md" ] && continue |
| 76 | + grep -q "$base" docs/README.md || bad "docs/README.md does not list docs/specs/$base" |
| 77 | + grep -q "$base" docs/zh/README.md || bad "docs/zh/README.md does not list docs/specs/$base" |
| 78 | + grep -q "$base" docs/specs/README.md || bad "docs/specs/README.md does not list $base" |
| 79 | +done |
| 80 | + |
| 81 | +# ── 5. every specification has a metadata table and a change record ────── |
| 82 | +# |
| 83 | +# docs/specs/README.md has required both since the directory existed. Nothing |
| 84 | +# checked, so the requirement held only for specs whose author read the README. |
| 85 | +# |
| 86 | +# Four rows are required rather than "a table", and the fourth is why: a |
| 87 | +# specification whose implementation version is not stated cannot be judged |
| 88 | +# stale by anyone. Two spellings are in use for it and both are accepted. |
| 89 | +for f in docs/specs/*.md; do |
| 90 | + base="$(basename "$f")" |
| 91 | + [ "$base" = "README.md" ] && continue |
| 92 | + meta="$(head -30 "$f")" |
| 93 | + for row in '规范编号' '状态' '最后修改'; do |
| 94 | + printf '%s' "$meta" | grep -q "$row" \ |
| 95 | + || bad "$f: metadata table has no \`$row\` row" |
| 96 | + done |
| 97 | + printf '%s' "$meta" | grep -qE '对应实现|最低实现版本' \ |
| 98 | + || bad "$f: metadata table states no implementation version, so nothing can decide whether it is stale" |
| 99 | + grep -qE '^#{2,3} .*(变更记录|Change record|Change log)' "$f" \ |
| 100 | + || bad "$f: no change record" |
| 101 | +done |
| 102 | + |
| 103 | +# ── 6. no emoji under docs/ or in a top-level README ───────────────────── |
| 104 | +# |
| 105 | +# Status is a word: 已实现 / 未实现, yes / no, verified / not verified. A symbol |
| 106 | +# needs a legend and a word does not. `.agents/docs/` is NOT checked: its |
| 107 | +# records carry four thousand of them and are immutable once their change |
| 108 | +# lands, so normalising them would edit documents whose value is that they are |
| 109 | +# not edited. New records follow the rule by review. |
| 110 | +EMOJI='[✅❌⚠⭐🎉🚀💡🔥👍✨📦🔧]' |
| 111 | +for f in docs/*.md docs/zh/*.md docs/specs/*.md README.md README.zh-CN.md; do |
| 112 | + [ -f "$f" ] || continue |
| 113 | + if grep -nP "$EMOJI" "$f" >/dev/null 2>&1; then |
| 114 | + while IFS= read -r hit; do |
| 115 | + bad "$f: emoji — state the status as a word: ${hit%%:*}: $(echo "$hit" | cut -d: -f2- | cut -c1-60)" |
| 116 | + done < <(grep -nP "$EMOJI" "$f") |
| 117 | + fi |
| 118 | +done |
| 119 | + |
| 120 | +# ── 7. the design-record index is current ──────────────────────────────── |
| 121 | +# |
| 122 | +# 269 records and the index was one heading. It is generated now, so it cannot |
| 123 | +# drift -- and a generated file that is checked in must be compared against the |
| 124 | +# generator or it drifts anyway. |
| 125 | +python3 .github/tools/gen_agents_index.py --check || fail=1 |
| 126 | + |
| 127 | +# ── 8. a new design record declares its subject and status ─────────────── |
| 128 | +# |
| 129 | +# From the date the convention starts. The 268 records that predate it are not |
| 130 | +# rewritten: a record describes the moment its change was made, and a pass that |
| 131 | +# added a field nobody chose would edit documents whose value is that they are |
| 132 | +# not edited. |
| 133 | +CONVENTION_FROM="2026-09-08" |
| 134 | +for f in .agents/docs/[0-9]*.md; do |
| 135 | + [ -f "$f" ] || continue |
| 136 | + d="$(basename "$f" | cut -c1-10)" |
| 137 | + [[ "$d" < "$CONVENTION_FROM" ]] && continue |
| 138 | + head -1 "$f" | grep -q '^---$' \ |
| 139 | + || { bad "$f: a record dated $CONVENTION_FROM or later has no front matter"; continue; } |
| 140 | + fmblock="$(awk 'NR>1 && /^---$/ {exit} NR>1' "$f")" |
| 141 | + printf '%s' "$fmblock" | grep -qE '^subject: *[a-z]' \ |
| 142 | + || bad "$f: front matter declares no \`subject\`" |
| 143 | + printf '%s' "$fmblock" | grep -qE '^status: *(active|landed|superseded|abandoned) *$' \ |
| 144 | + || bad "$f: front matter declares no valid \`status\` (active | landed | superseded | abandoned)" |
| 145 | +done |
| 146 | + |
| 147 | +# ── 9. every relative link in docs/ and examples/ resolves, fragment included ─ |
| 148 | +# |
| 149 | +# Rule 3 catches `docs/NN-*.md` named anywhere, including from source comments. |
| 150 | +# This is the other half: a Markdown link in a document that points at a file |
| 151 | +# which is not there. Both halves are needed -- a chapter moved in this batch |
| 152 | +# would satisfy one and break the other. |
| 153 | +# |
| 154 | +# THE FRAGMENT IS PART OF THE LINK. The first version of this rule discarded |
| 155 | +# it (`(?:#[^)]*)?`), so a link to a section that had been renamed resolved to |
| 156 | +# the file and was reported correct. Renaming 100 headings for register in one |
| 157 | +# batch is exactly the change that produces those, and two hand-written anchors |
| 158 | +# in chapter 30 were already wrong before the renames began. |
| 159 | +python3 - <<'PYCHECK' || fail=1 |
| 160 | +import re, pathlib, sys, unicodedata |
| 161 | +
|
| 162 | +def slug(heading): |
| 163 | + """GitHub's heading slug: lowercase, drop punctuation and symbols, spaces to hyphens. |
| 164 | +
|
| 165 | + Category P* and S* covers what github-slugger removes -- ASCII punctuation, |
| 166 | + the em dash, the backticks around inline code, and the full-width comma and |
| 167 | + colon the Chinese chapters use -- while `-` and `_` are kept because an |
| 168 | + anchor is allowed to contain them. |
| 169 | + """ |
| 170 | + t = re.sub(r"^#+\s+", "", heading).strip().lower() |
| 171 | + keep = [] |
| 172 | + for ch in t: |
| 173 | + if ch in "-_": |
| 174 | + keep.append(ch) |
| 175 | + elif ch.isspace(): |
| 176 | + keep.append(" ") |
| 177 | + elif unicodedata.category(ch)[0] in "PS": |
| 178 | + continue |
| 179 | + else: |
| 180 | + keep.append(ch) |
| 181 | + return "".join(keep).replace(" ", "-") |
| 182 | +
|
| 183 | +def anchors_of(path): |
| 184 | + """Every anchor the file defines, with GitHub's -1/-2 suffix for repeats.""" |
| 185 | + seen, out, infence = {}, set(), False |
| 186 | + for line in path.read_text(errors="ignore").splitlines(): |
| 187 | + if line.startswith("```"): |
| 188 | + infence = not infence |
| 189 | + continue |
| 190 | + if infence or not re.match(r"^#{1,6} ", line): |
| 191 | + continue |
| 192 | + s = slug(line) |
| 193 | + n = seen.get(s, 0) |
| 194 | + seen[s] = n + 1 |
| 195 | + out.add(s if n == 0 else f"{s}-{n}") |
| 196 | + return out |
| 197 | +
|
| 198 | +files = list(pathlib.Path("docs").rglob("*.md")) + list(pathlib.Path("examples").rglob("*.md")) |
| 199 | +cache = {} |
| 200 | +bad = 0 |
| 201 | +for f in files: |
| 202 | + for m in re.finditer(r"\]\(([^)\s]*?)(?:#([^)\s]+))?\)", f.read_text(errors="ignore")): |
| 203 | + t, frag = m.group(1), m.group(2) |
| 204 | + if t.startswith(("http", "mailto:")): |
| 205 | + continue |
| 206 | + target = (f.parent / t) if t else f |
| 207 | + if t and not target.exists(): |
| 208 | + print(f"FAIL: {f}: link to `{t}` does not resolve") |
| 209 | + bad += 1 |
| 210 | + continue |
| 211 | + if not frag or target.suffix != ".md" or not target.is_file(): |
| 212 | + continue |
| 213 | + key = target.resolve() |
| 214 | + if key not in cache: |
| 215 | + cache[key] = anchors_of(target) |
| 216 | + if frag not in cache[key]: |
| 217 | + print(f"FAIL: {f}: `#{frag}` is not a heading in {target.as_posix()}") |
| 218 | + bad += 1 |
| 219 | +sys.exit(1 if bad else 0) |
| 220 | +PYCHECK |
| 221 | + |
| 222 | +# ── 10. a translation carries the same tables and code blocks ──────────── |
| 223 | +# |
| 224 | +# check_docs_style.sh compares HEADING STRUCTURE, which is what catches a page |
| 225 | +# that has fallen a section behind. It does not see a table row or a code block |
| 226 | +# that never made it across, and two of those were sitting in the tree: the |
| 227 | +# 简体中文 `[features]` section had no body at all, and 简体中文 §2.11 was |
| 228 | +# missing the `identity` verdict table. Both predate this check and both are |
| 229 | +# invisible to every other one. |
| 230 | +python3 - <<'PYPARITY' || fail=1 |
| 231 | +import pathlib, sys, re |
| 232 | +bad = 0 |
| 233 | +for en in sorted(pathlib.Path("docs").glob("*.md")): |
| 234 | + zh = pathlib.Path("docs/zh") / en.name |
| 235 | + if not zh.exists(): |
| 236 | + continue |
| 237 | + def count(f): |
| 238 | + rows = blocks = 0 |
| 239 | + infence = False |
| 240 | + for line in f.read_text(errors="ignore").split("\n"): |
| 241 | + if line.startswith("```"): |
| 242 | + if not infence: |
| 243 | + blocks += 1 |
| 244 | + infence = not infence |
| 245 | + continue |
| 246 | + if infence: |
| 247 | + continue |
| 248 | + if line.startswith("|"): |
| 249 | + rows += 1 |
| 250 | + return rows, blocks |
| 251 | + er, eb = count(en) |
| 252 | + zr, zb = count(zh) |
| 253 | + if er != zr: |
| 254 | + print(f"FAIL: {en.name}: {er} table rows in English, {zr} in 简体中文") |
| 255 | + bad += 1 |
| 256 | + if eb != zb: |
| 257 | + print(f"FAIL: {en.name}: {eb} code blocks in English, {zb} in 简体中文") |
| 258 | + bad += 1 |
| 259 | +sys.exit(1 if bad else 0) |
| 260 | +PYPARITY |
| 261 | + |
| 262 | +# ── 11. every chapter states its reader and its question ───────────────── |
| 263 | +# |
| 264 | +# `.agents/skills/mcpp-docs-style` R3: reader, the one question, and the |
| 265 | +# EXCLUSIONS, in the first fifteen lines. The exclusions are the load-bearing |
| 266 | +# half -- they are the gate that stops a chapter re-absorbing a topic another |
| 267 | +# chapter owns. Five of 24 chapters had this before the design; a rule nothing |
| 268 | +# checks is a rule that decays back to five. |
| 269 | +# Chapter 00 is exempt: it is the book's front door, and a metadata block is a |
| 270 | +# reference-chapter device. It has no "not here" to declare, because everything |
| 271 | +# else IS elsewhere -- which is what its closing paragraph says instead. |
| 272 | +for f in docs/[0-9]*.md docs/zh/[0-9]*.md; do |
| 273 | + [ -f "$f" ] || continue |
| 274 | + case "$(basename "$f")" in 00-*) continue ;; esac |
| 275 | + head -18 "$f" | grep -qE '^\*\*(Reader|读者)' \ |
| 276 | + || bad "$f: no designed opening — the first lines must name the reader" |
| 277 | + head -18 "$f" | grep -qE '(question this chapter answers|本章回答的那一个问题)' \ |
| 278 | + || bad "$f: the opening names no question" |
| 279 | + head -22 "$f" | grep -qE '^\*\*(Not here|不在这里)' \ |
| 280 | + || bad "$f: the opening states no exclusions" |
| 281 | +done |
| 282 | + |
| 283 | +# ── 12. a citation that names a section lands in the chapter that has it ─ |
| 284 | +# |
| 285 | +# `See *One package, one version* in [04](04-mcpp-toml.md)` survived a split |
| 286 | +# that moved the section to chapter 23, in five places and two languages. Rule 3 |
| 287 | +# could not see it -- the path resolved; it was the wrong chapter. A citation |
| 288 | +# that names a section by TITLE is checkable against that chapter's headings. |
| 289 | +python3 - <<'PYCITE' || fail=1 |
| 290 | +import re, pathlib, sys |
| 291 | +EN = re.compile(r"See \*([^*]{3,60})\* in \[\d{2}[^\]]*\]\((\d{2}-[a-z0-9-]+)\.md\)") |
| 292 | +ZH = re.compile(r"见\s*\[\d{2}[^\]]*\]\((\d{2}-[a-z0-9-]+)\.md\)\s*的\*([^*]{2,40})\*") |
| 293 | +bad = 0 |
| 294 | +for f in list(pathlib.Path("docs").glob("[0-9]*.md")) + list(pathlib.Path("docs/zh").glob("[0-9]*.md")): |
| 295 | + text = f.read_text(errors="ignore") |
| 296 | + for m in EN.finditer(text): |
| 297 | + title, chap = m.group(1), m.group(2) |
| 298 | + t = (f.parent / f"{chap}.md") |
| 299 | + if not t.exists() or title.lower() not in t.read_text(errors="ignore").lower(): |
| 300 | + print(f"FAIL: {f}: cites *{title}* in {chap}, which does not contain it"); bad += 1 |
| 301 | + for m in ZH.finditer(text): |
| 302 | + chap, title = m.group(1), m.group(2) |
| 303 | + t = (f.parent / f"{chap}.md") |
| 304 | + if not t.exists() or title not in t.read_text(errors="ignore"): |
| 305 | + print(f"FAIL: {f}: cites *{title}* in {chap}, which does not contain it"); bad += 1 |
| 306 | +sys.exit(1 if bad else 0) |
| 307 | +PYCITE |
| 308 | + |
| 309 | +# ── 13. every table the manifest reference documents is in the lookup index ─ |
| 310 | +# |
| 311 | +# docs/README.md carries a reverse index -- a key in front of a reader to the |
| 312 | +# chapter that owns it -- and it is hand-built. A key added to the reference and |
| 313 | +# not indexed is invisible: the reader concludes it is undocumented. The |
| 314 | +# denominator is the reference chapter's own `###` headings, and the comparison |
| 315 | +# is on the KEY NAME rather than on its spelling, because the two documents |
| 316 | +# legitimately write `[targets.<name>]` and `[targets.<n>]`. |
| 317 | +python3 - <<'PYLOOKUP' || fail=1 |
| 318 | +import re, pathlib, sys |
| 319 | +ref = pathlib.Path("docs/04-mcpp-toml.md").read_text(errors="ignore") |
| 320 | +idx = pathlib.Path("docs/README.md").read_text(errors="ignore") |
| 321 | +keys = set() |
| 322 | +for m in re.finditer(r"^### [0-9.b]+ `([^`]+)`", ref, re.M): |
| 323 | + tok = m.group(1) |
| 324 | + name = re.sub(r"^\[|\].*$", "", tok) # [targets.<name>] -> targets.<name> |
| 325 | + name = name.split(".")[0].split(" ")[-1] # -> targets ; "[package] platforms" -> platforms |
| 326 | + keys.add(name) |
| 327 | +missing = sorted(k for k in keys if k not in idx) |
| 328 | +for k in missing: |
| 329 | + print(f"FAIL: docs/README.md lookup index does not mention `{k}`, which docs/04 documents") |
| 330 | +sys.exit(1 if missing else 0) |
| 331 | +PYLOOKUP |
| 332 | + |
| 333 | +if [[ "$fail" -eq 0 ]]; then |
| 334 | + echo "OK: docs structure checks pass" |
| 335 | +fi |
| 336 | +exit "$fail" |
0 commit comments