新建 plan/设计档 frontmatter 必带一行 上级:(仓根相对路径),沿链 6 跳内达 canonical SoT; 全链/树/当前位置都是查询结果(plan-tree.py)而非维护对象,_index 在飞板保持线级、不加逐叶登记。 G7 拦三种腐坏:缺字段 / 上级死链 / 链断(中途档既非 canonical 又无上级),成环与超跳同挡(均已植坏档实测命中)。 存量修复:统一执行计划基建线补认领配置控制面设计(治真断链、含 07-01 SCA 选型反转口径), 配置控制面设计与阶段〇/一① plan 补 上级:;AGENTS.md/.agents README/feature-design-doc 模板同步七检口径。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
289 lines
12 KiB
Python
289 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""文档治理门(engineering-conventions §10.7 的可执行实现 · 2026-07-02 立)
|
|
|
|
七检,任一失败退出码非零;每检打印通过/命中明细:
|
|
G1 品牌不变量 活层禁退役品牌根(白名单:docs/ip、_archive、_recall、日期留痕档、行内 brand-ok 标)
|
|
G2 canonical `canonical: true` 的 topic 全仓唯一,且与 docs/architecture/README.md 注册表双向对账
|
|
G3 死链 活层 md 的 []() 相对链接与反引号内 docs/·.agents/ 仓内路径必须存在(git show 定位放行)
|
|
G4 入口卫生 AGENTS.md 禁 git clone / 全局安装 / 个人绝对路径 / localhost
|
|
G5 留痕隔离 策展层(AGENTS.md、docs/architecture、.agents)不得链接非 canonical 的留痕档(plans/brainstorms/dated specs;_index.md 与 canonical 目标豁免)
|
|
G6 设计档申报 docs/agent-specs/*-设计.md 必带 frontmatter topic/status/sot-impact
|
|
G7 计划谱系 新建 plan/设计档必带 frontmatter `上级:`(仓根相对路径单指针),沿链 6 跳内达 canonical SoT;死链/断链/成环即挡(engineering-conventions §10.8)
|
|
|
|
用法:python3 .agents/tools/docs-gate.py [仓库根,缺省=脚本上两级]
|
|
挂载:.githooks/pre-commit(git config core.hooksPath .githooks)、.gitea/workflows/docs-gate.yml、wave-close 第 8 步。
|
|
门③(doc↔code 兑现断言)不在本脚本:归生成主线 harness,规格见 engineering-conventions §10.7。
|
|
"""
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
# 退役品牌根的唯一字面量(活层散文一律用「退役品牌名」间接表述,勿再写出字面量)
|
|
BRAND_RETIRED = "造梦"
|
|
|
|
ROOT = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.abspath(
|
|
os.path.join(os.path.dirname(__file__), "..", ".."))
|
|
|
|
DATED = re.compile(r"^\d{4}-\d{2}-\d{2}-")
|
|
MD_LINK = re.compile(r"\[[^\]]*\]\(([^)]+)\)")
|
|
BACKTICK = re.compile(r"`([^`\n]+)`")
|
|
FAILS = []
|
|
|
|
|
|
def rel(p):
|
|
return os.path.relpath(p, ROOT)
|
|
|
|
|
|
def walk_md(bases, exclude_archives=True):
|
|
out = []
|
|
for base in bases:
|
|
top = os.path.join(ROOT, base)
|
|
if os.path.isfile(top):
|
|
out.append(top)
|
|
continue
|
|
for dp, dns, fs in os.walk(top):
|
|
dp_rel = rel(dp) + "/"
|
|
if "node_modules" in dp_rel or "/target/" in dp_rel:
|
|
continue
|
|
if exclude_archives and ("_archive" in dp_rel or "_recall" in dp_rel):
|
|
continue
|
|
if dp_rel.startswith("docs/ip/"):
|
|
continue
|
|
for fn in fs:
|
|
if fn.endswith(".md"):
|
|
out.append(os.path.join(dp, fn))
|
|
return out
|
|
|
|
|
|
def read(fp):
|
|
try:
|
|
with open(fp, encoding="utf-8", errors="ignore") as f:
|
|
return f.read()
|
|
except OSError:
|
|
return ""
|
|
|
|
|
|
def frontmatter(fp):
|
|
"""返回 frontmatter 字段 dict(浅解析,只认顶层 `key: value` 行)。"""
|
|
lines = read(fp).splitlines()
|
|
if not lines or lines[0].strip() != "---":
|
|
return {}
|
|
fm = {}
|
|
for line in lines[1:60]:
|
|
if line.strip() == "---":
|
|
break
|
|
m = re.match(r"^([A-Za-z一-鿿][\w\-一-鿿]*):\s*(.*)$", line)
|
|
if m:
|
|
fm[m.group(1)] = m.group(2).split("#")[0].strip()
|
|
return fm
|
|
|
|
|
|
def check(name, problems):
|
|
if problems:
|
|
FAILS.append(name)
|
|
print(f"✘ {name}: {len(problems)} 处")
|
|
for p in problems:
|
|
print(f" {p}")
|
|
else:
|
|
print(f"✔ {name}")
|
|
|
|
|
|
# ---------- G1 品牌不变量 ----------
|
|
def g1():
|
|
problems = []
|
|
for fp in walk_md(["AGENTS.md", "docs", ".agents"]):
|
|
if DATED.match(os.path.basename(fp)):
|
|
continue
|
|
for i, line in enumerate(read(fp).splitlines(), 1):
|
|
if BRAND_RETIRED in line and "brand-ok" not in line:
|
|
problems.append(f"{rel(fp)}:{i} {line.strip()[:60]}")
|
|
check("G1 品牌不变量(活层无退役品牌根)", problems)
|
|
|
|
|
|
# ---------- G2 canonical 唯一性 + 注册表对账 ----------
|
|
def g2():
|
|
problems = []
|
|
canon = {} # topic -> [paths]
|
|
for fp in walk_md(["docs", ".agents"], exclude_archives=False):
|
|
fm = frontmatter(fp)
|
|
if fm.get("canonical") == "true":
|
|
if "_archive" in rel(fp) or "_recall" in rel(fp):
|
|
problems.append(f"归档层不得标 canonical:{rel(fp)}")
|
|
continue
|
|
topic = fm.get("topic", "")
|
|
if not topic:
|
|
problems.append(f"canonical 缺 topic:{rel(fp)}")
|
|
continue
|
|
canon.setdefault(topic, []).append(rel(fp))
|
|
for topic, paths in sorted(canon.items()):
|
|
if len(paths) > 1:
|
|
problems.append(f"topic「{topic}」有 {len(paths)} 份 canonical:{' | '.join(paths)}")
|
|
# 注册表对账
|
|
readme = os.path.join(ROOT, "docs/architecture/README.md")
|
|
reg = {} # topic -> path(相对仓根)
|
|
for row in read(readme).splitlines():
|
|
if not row.startswith("|") or "canonical" not in row:
|
|
continue
|
|
cells = [c.strip() for c in row.strip().strip("|").split("|")]
|
|
if len(cells) < 3 or cells[2] != "canonical":
|
|
continue
|
|
m = MD_LINK.search(cells[1])
|
|
if not m:
|
|
problems.append(f"注册表行缺链接:{row.strip()[:70]}")
|
|
continue
|
|
tgt = os.path.normpath(os.path.join(os.path.dirname(readme), m.group(1).split("#")[0]))
|
|
reg[cells[0]] = rel(tgt)
|
|
reg_topics, canon_topics = set(reg), set(canon)
|
|
for t in sorted(reg_topics - canon_topics):
|
|
problems.append(f"注册表有、frontmatter 无 canonical:「{t}」→ {reg.get(t)}")
|
|
for t in sorted(canon_topics - reg_topics):
|
|
problems.append(f"文档标了 canonical、注册表未登记:「{t}」({canon[t][0]})")
|
|
for t in sorted(reg_topics & canon_topics):
|
|
if reg[t] not in canon[t]:
|
|
problems.append(f"topic「{t}」注册表指 {reg[t]},frontmatter 在 {canon[t][0]}")
|
|
check("G2 canonical 唯一性 + 注册表对账", problems)
|
|
|
|
|
|
# ---------- G3 死链 ----------
|
|
def g3():
|
|
problems = []
|
|
for fp in walk_md(["AGENTS.md", "docs", ".agents"]):
|
|
txt = read(fp)
|
|
dirn = os.path.dirname(fp) or ROOT
|
|
for m in MD_LINK.finditer(txt):
|
|
t = m.group(1).strip()
|
|
if t.startswith(("http://", "https://", "#", "mailto:", "<", "@")) or "," in t:
|
|
continue
|
|
t = t.split("#")[0].split(" ")[0].strip()
|
|
if not t:
|
|
continue
|
|
tgt = os.path.normpath(os.path.join(dirn, t))
|
|
if not os.path.exists(tgt):
|
|
problems.append(f"{rel(fp)} -> {m.group(1)}")
|
|
for line in txt.splitlines():
|
|
if "git show" in line:
|
|
continue
|
|
for m in BACKTICK.finditer(line):
|
|
t = m.group(1).strip()
|
|
tok = t.split()[0] if t.split() else t
|
|
if not re.match(r"^(docs|\.agents)/", tok):
|
|
continue
|
|
if any(c in tok for c in "{}*<>$#") or "YYYY" in tok:
|
|
continue
|
|
cand = os.path.join(ROOT, tok.rstrip("/"))
|
|
if not (os.path.exists(cand) or os.path.exists(cand + ".md")):
|
|
problems.append(f"{rel(fp)} -> `{t}`(反引号仓内路径不存在)")
|
|
check("G3 死链(md 链接 + 反引号仓内路径)", problems)
|
|
|
|
|
|
# ---------- G4 入口卫生 ----------
|
|
def g4():
|
|
problems = []
|
|
patterns = ["git clone", "~/.claude", "install -g", "http://localhost", "http://127."]
|
|
for i, line in enumerate(read(os.path.join(ROOT, "AGENTS.md")).splitlines(), 1):
|
|
for p in patterns:
|
|
if p in line:
|
|
problems.append(f"AGENTS.md:{i} 含「{p}」")
|
|
check("G4 入口卫生(AGENTS.md)", problems)
|
|
|
|
|
|
# ---------- G5 留痕隔离 ----------
|
|
def g5():
|
|
problems = []
|
|
trace_prefix = ("docs/plans/", "docs/brainstorms/", "docs/memorys/")
|
|
for fp in walk_md(["AGENTS.md", "docs/architecture", ".agents"]):
|
|
dirn = os.path.dirname(fp) or ROOT
|
|
for m in MD_LINK.finditer(read(fp)):
|
|
t = m.group(1).strip()
|
|
if t.startswith(("http://", "https://", "#", "mailto:", "<", "@")) or "," in t:
|
|
continue
|
|
t = t.split("#")[0].strip()
|
|
if not t:
|
|
continue
|
|
tgt = os.path.normpath(os.path.join(dirn, t))
|
|
tr = rel(tgt)
|
|
hit = tr.startswith(trace_prefix) or (
|
|
tr.startswith("docs/agent-specs/") and DATED.match(os.path.basename(tr)))
|
|
if not hit:
|
|
continue
|
|
if os.path.basename(tr) == "_index.md":
|
|
continue
|
|
if os.path.exists(tgt) and frontmatter(tgt).get("canonical") == "true":
|
|
continue # 链向 canonical 活档合法
|
|
problems.append(f"{rel(fp)} -> {tr}(策展层链非 canonical 留痕)")
|
|
check("G5 留痕隔离(策展层不链留痕档)", problems)
|
|
|
|
|
|
# ---------- G6 设计档申报 ----------
|
|
def g6():
|
|
problems = []
|
|
specs = os.path.join(ROOT, "docs/agent-specs")
|
|
if os.path.isdir(specs):
|
|
for fn in os.listdir(specs):
|
|
if fn.endswith("-设计.md"):
|
|
fm = frontmatter(os.path.join(specs, fn))
|
|
missing = [k for k in ("topic", "status", "sot-impact") if not fm.get(k)]
|
|
if missing:
|
|
problems.append(f"docs/agent-specs/{fn} 缺 frontmatter:{','.join(missing)}")
|
|
check("G6 设计档申报(topic/status/sot-impact)", problems)
|
|
|
|
|
|
# ---------- G7 计划谱系(单指针 上级:) ----------
|
|
G7_CUTOFF = "2026-07-03" # 生效日:文件名日期 >= 此日的新档必带 上级:;存量自愿(带了就验)
|
|
G7_MAX_HOPS = 6 # 谱系链跳数上限(计划树实际 3-4 级,6 留余量)
|
|
|
|
|
|
def g7():
|
|
"""计划/设计档谱系门:每档一行 `上级:`(仓根相对路径),沿链必达 canonical SoT。
|
|
|
|
只验证「单指针 + 链可达」这条最小事实(engineering-conventions §10.8);
|
|
全链/树/当前位置都是查询视图(.agents/tools/plan-tree.py),不在门内维护。
|
|
"""
|
|
problems = []
|
|
holders = [] # 所有带 上级: 的档(存量自愿带了也验)
|
|
for base, suffix in (("docs/plans", "-plan.md"), ("docs/agent-specs", "-设计.md")):
|
|
d = os.path.join(ROOT, base)
|
|
if not os.path.isdir(d):
|
|
continue
|
|
for fn in sorted(os.listdir(d)):
|
|
if not fn.endswith(suffix) or not DATED.match(fn):
|
|
continue
|
|
fp = os.path.join(d, fn)
|
|
fm = frontmatter(fp)
|
|
if fn[:10] >= G7_CUTOFF and not fm.get("上级"):
|
|
problems.append(f"{rel(fp)} 缺 frontmatter `上级:`(新档必须单指针挂进计划谱系,§10.8)")
|
|
elif fm.get("上级"):
|
|
holders.append(fp)
|
|
for fp in holders:
|
|
cur, seen = fp, set()
|
|
for _ in range(G7_MAX_HOPS + 1):
|
|
fm = frontmatter(cur)
|
|
if fm.get("canonical") == "true" and cur != fp:
|
|
break # 达 canonical SoT,链成立
|
|
parent = fm.get("上级")
|
|
if not parent:
|
|
problems.append(f"{rel(fp)} 谱系断链:{rel(cur)} 既非 canonical 也无 `上级:`(上级需先认领这条线)")
|
|
break
|
|
tgt = os.path.normpath(os.path.join(ROOT, parent))
|
|
if not os.path.isfile(tgt):
|
|
problems.append(f"{rel(fp)} 上级死链:{rel(cur)} -> {parent}")
|
|
break
|
|
if tgt in seen or tgt == cur:
|
|
problems.append(f"{rel(fp)} 谱系成环:{parent}")
|
|
break
|
|
seen.add(cur)
|
|
cur = tgt
|
|
else:
|
|
problems.append(f"{rel(fp)} 谱系超 {G7_MAX_HOPS} 跳未达 canonical SoT")
|
|
check("G7 计划谱系(`上级:` 单指针沿链达 canonical)", problems)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
for fn in (g1, g2, g3, g4, g5, g6, g7):
|
|
fn()
|
|
if FAILS:
|
|
print(f"--- docs-gate 未过:{len(FAILS)} 检失败({'、'.join(FAILS)}) ---")
|
|
sys.exit(len(FAILS))
|
|
print("--- docs-gate 全绿 ---")
|