逐条读当前代码坐实(不凭文档推断):16 条 = 7 moot / 6 已修 / 1 存活待修 / 2 本次修; 另协调者追加第 17 条(ad-slot 契约漂移)本次修。对账表全文入总账 §5。 wg1 六项随 Node 退役 moot(生产便宜档=cheap-worker,幂等/max_tokens/callback 已在替代解决); tier2 成本硬闸(item3)经配置控制面阶段一① soft_budget 演进已修(单 POST 内联续修 + CircuitBreakerMiddleware 会话级 spent_rmb 累加 fail-closed);CSP/vue-i18n/validate_play_scene/ 契约真空/服务态主链等 已修;gamedef v1 标注漂移随文件删除 moot。 本次动手修: - G1 品牌门扫描面有洞(item8): docs-gate.py 加 walk_code 扫业务代码目录(模块根 package.json/index.html + wg1/ 等源码),旧门只扫 .md 会放过玩家/管理台可见字面量; 负向测试证真能拦回流。 - 品牌残留漏网(item8): game-admin 菜单标题退役品牌名 → 绘境运营(扩面后被 G1 逮出)。 - ad-slot 契约漂移(R4-17,跨端契约): ad-slot.schema.json provider enum 加 callback (additive)+ 对齐描述;与 api-schemas/ad.yaml 及 ad 模块 CallbackAdProvider(真实现 PROVIDER="callback")对齐。DB VARCHAR 注释/DO Javadoc 仍列 csj/gdt/mock=doc-only (SQL 为 Flyway 版本档不改 checksum),留终审。 - docs-gate.sh 壳注释 六检→七检(失实修正)。 存活待修: item12 tier2 bootstrap X-User-Id 鉴权头口子 + 无重试退避(需真实 deps.py 鉴权契约,部署 smoke 接缝,非本机最小改动可完成)。 验证: docs-gate 七检全绿 + G1 负向测试(植入即拦/删除回绿) + ad-slot JSON 合法 + docs-gate.py 编译 + CallbackAdProviderTest 印证 getProvider()="callback"。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
321 lines
14 KiB
Python
321 lines
14 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 = "造梦"
|
|
|
|
# G1 品牌不变量的代码扫描面(W-REAL R4 补:防品牌回流到模块根 package.json / index.html 与 wg1/ 等代码——
|
|
# 评审曾指出门只扫活层 md 会放过玩家/管理台可见的代码字面量)。业务代码目录 + 相关源码后缀;
|
|
# .agents 不入(门自身源码持有 BRAND_RETIRED 字面量,扫它会自噬)、docs 由 walk_md 覆盖。
|
|
BRAND_CODE_BASES = ["game-runtime", "game-studio", "game-admin", "wg1", "tier2",
|
|
"cheap-worker", "game-cloud", "contracts", "deploy", "spikes"]
|
|
BRAND_CODE_EXTS = (".json", ".html", ".ts", ".js", ".mjs", ".cjs", ".vue",
|
|
".py", ".java", ".yaml", ".yml")
|
|
# 依赖/构建产物/归档目录原地裁剪(既防误报也快)。
|
|
BRAND_PRUNE_DIRS = {"node_modules", "target", "dist", "build", ".git",
|
|
"_archive", "_recall", ".venv", "__pycache__"}
|
|
|
|
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 walk_code(bases, exts):
|
|
"""遍历业务代码目录里指定后缀的源文件(供 G1 品牌扫描面防回流用;prune 掉依赖/构建/归档目录)。"""
|
|
out = []
|
|
for base in bases:
|
|
top = os.path.join(ROOT, base)
|
|
if not os.path.isdir(top):
|
|
continue
|
|
for dp, dns, fs in os.walk(top):
|
|
dns[:] = [d for d in dns if d not in BRAND_PRUNE_DIRS] # 原地裁剪不进依赖/构建目录
|
|
for fn in fs:
|
|
if fn.endswith(exts): # str.endswith 接受后缀元组
|
|
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 = []
|
|
# ① 活层策展 md(AGENTS.md / docs / .agents):日期留痕档豁免。
|
|
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]}")
|
|
# ② 业务代码目录源文件(W-REAL R4 补扫描面):模块根 package.json / index.html 与 wg1/ 等代码防品牌回流。
|
|
for fp in walk_code(BRAND_CODE_BASES, BRAND_CODE_EXTS):
|
|
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 品牌不变量(活层 md + 业务代码无退役品牌根)", 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 全绿 ---")
|