#!/usr/bin/env python3 # -*- coding: utf-8 -*- """交付物**用词**闸门 —— 与 ③段 `scripts/check_naming.py` 同族:那个查「文本一致性」,这个查「用词」。 为什么需要它(不是"为了严谨",是实测过的复发事故): 「宿主」这个词在本工作区有两义 —— **被嵌入的目标产品**(product-planning 语境,本项目=tiaoyue) 与**那个程序**(机制语境,正式名 `WorkBuddy 本体`)。同形不同义 ⇒ 读者无从分辨。 既有规矩只把「内部词须 0」查在**原型三页 HTML**上,评审产物(`产品规划复盘-*/` 这类)不受管 ⇒ 晦涩词在交付物里复发(实测一次 48 处)。本脚本把检查面扩到**所有交付物**。 三条检查(`--paths` 指到哪就只查哪,⛔ 不隐式全盘扫): ① **内部词表 0 命中** —— 除「引用」外一律 FAIL。被判为「引用」而放行的,降级为 HINT。 ② **同一实体两种叫法同篇** —— 同一文件里两个名字都在用 ⇒ FAIL(一个东西只许一个名)。 ③ **机制侧用词越界** —— 把 `WorkBuddy 本体` 写成「宿主」,且同句还有机制侧词 ⇒ FAIL。 判定「引用」的三条(满足任一即放行,只报 HINT): · 该词被成对引号包住(`` ` `` / 「」 / 『』 / “” / ""); · 同句带处置标记(旧名 / 已废 / 已改名 / 改名 / 改成 / 删掉 / 删除 / 整节 / 废止 / 历史 / 曾是 / 原节名 / 待改 / 旧口径 / 未落地 / 并入 / 不一致); · 同一行命中 **≥2 个内部词** ⇒ 判为「节序对照句」。 ⚠️ 已知局限(如实写在这里,别当成没有):这三条是**文本启发式**,判不出「表格里把旧名当现状陈述」 这类用法 —— 那种句子会走第 3 条放行。⇒ 本脚本拦「新写的正文里误用旧名/内部词」,⛔ 不替代人工回读。 用法: python scripts/check_terms.py --paths <交付物目录或文件> [--paths …] python scripts/check_terms.py --paths D:/某工作区/交付物 --ext .md 退出码:0 = 全过(可能带 HINT);1 = 有 FAIL;2 = 用法/路径错。 """ import argparse import os import re import sys try: sys.stdout.reconfigure(encoding="utf-8", errors="replace") sys.stderr.reconfigure(encoding="utf-8", errors="replace") except Exception: pass # ── ① 内部词表。加词前先确认「它真的会误导读者」,不是「看着不顺眼」────────────── INTERNAL = { "宿主": "内部词:指被嵌入的产品时写「目标产品」(首次出现写「目标产品(本项目=tiaoyue)」);" "指那个程序时写「WorkBuddy 本体」", "跨页与界面状态": "已废节名(2026-10-15 用户令改名为「页面导航」)", "待确认事项与偏离单": "已删除的节(2026-10-15 用户令整节删除,⛔ 别补回来)", "待确认与偏离": "已删除的节(同上)", } # ── ② 同一实体两种叫法。左=正式名,右=旧称/别名;两者同篇非引用出现 ⇒ FAIL ────── ALIAS_PAIRS = [ ("目标产品", "宿主", "被嵌入的那个产品(product-planning 语境)"), ("WorkBuddy 本体", "宿主", "那个程序(机制语境)"), ] # ── ③ 机制侧标记。出现「宿主」且同句带这些词 ⇒ 越界(本轮旧词用的是机制义)──────── MECH_MARKERS = ("WorkBuddy 本体", "本体", "进程", "常驻", "守护", "会话机制", "机制侧", "manifest", "锁", "钩子", "注入面") # 「引用」放行用的处置标记 CITE_MARKERS = ("旧名", "已废", "已改名", "改名", "改成", "删掉", "删除", "整节", "废止", "历史", "曾是", "原节名", "待改", "旧口径", "未落地", "并入", "不一致") QUOTE_PAIRS = ("`", "「", "」", "『", "』") # 扫描时跳过的目录(`assets/` 是采集来的存量资产,本工作区惯例豁免) SKIP_DIRS = {"__pycache__", "_留痕", "归档", "assets", "node_modules", ".git", "vendor", "tmp"} def _quoted(line, word): """该词在本行是否被成对引号包住(`` ` `` | 「」 | 『』 | "" | “”)。""" for a, b in (("`", "`"), ("「", "」"), ("『", "』"), ('"', '"'), ("“", "”")): if re.search(re.escape(a) + r"[^" + re.escape(a + b) + r"]*" + re.escape(word) + r"[^" + re.escape(a + b) + r"]*" + re.escape(b), line): return True return False def _citations(line, hits): """本行里的这些命中,哪些算「引用」(⇒ 放行,只报 HINT)。""" if any(m in line for m in CITE_MARKERS): return True if len(hits) >= 2: # 同一行 ≥2 个内部词 ⇒ 判为节序对照句 return True return all(_quoted(line, w) for w in hits) def collect(paths, exts): files = [] for p in paths: p = os.path.abspath(p) if os.path.isfile(p): files.append(p) continue if not os.path.isdir(p): raise SystemExit("⛔ 路径不存在:%s" % p) for dp, dn, fn in os.walk(p): dn[:] = [d for d in dn if d not in SKIP_DIRS] for f in fn: if f.startswith("_superseded"): continue if os.path.splitext(f)[1].lower() in exts: files.append(os.path.join(dp, f)) return sorted(set(files)) def check_file(path): """返回 (fails, hints, passes) 三个字符串列表。""" fails, hints, passes = [], [], [] with open(path, encoding="utf-8", errors="ignore") as f: text = f.read() lines = text.split("\n") n_cited = 0 # ① 内部词表 + ③ 机制侧越界 for i, line in enumerate(lines, 1): hits = [w for w in INTERNAL if w in line] if not hits: continue if _citations(line, hits): n_cited += len(hits) continue for w in hits: fails.append("① 内部词 `%s` @ 行 %d —— %s\n %s" % (w, i, INTERNAL[w], line.strip()[:110])) if w == "宿主" and any(m in line for m in MECH_MARKERS): fails.append("③ 机制侧越界:这一句把 `WorkBuddy 本体` 写成了「宿主」@ 行 %d\n %s" % (i, line.strip()[:110])) # ② 同一实体两种叫法同篇(只算**非引用**命中:引号里的「待改对象」不算在用旧名) live = [] for a, b, what in ALIAS_PAIRS: na = sum(1 for line in lines if a in line and not _citations(line, [w for w in INTERNAL if w in line])) nb = sum(1 for line in lines if b in line and not _citations(line, [w for w in INTERNAL if w in line])) live.append((a, b, what, na, nb)) for a, b, what, na, nb in live: if na and nb: fails.append("② 同一实体两种叫法同篇:`%s` × %d 行 与 `%s` × %d 行 同时出现 —— %s 只许一个名" % (a, na, b, nb, what)) if n_cited: hints.append("引用了 %d 处内部词/旧名(判为「待改对象」,放行)" % n_cited) passes.append("① 内部词表(非引用命中=0)|② 别名同篇|③ 机制侧越界 — 均无 FAIL") return fails, hints, passes def main(): ap = argparse.ArgumentParser(description="交付物用词闸门(内部词 / 别名 / 机制侧越界)") ap.add_argument("--paths", action="append", required=True, help="交付物目录或文件,可重复;⛔ 不隐式全盘扫") ap.add_argument("--ext", default=".md,.html", help="扫哪些扩展名(逗号分隔,缺省 .md,.html)") a = ap.parse_args() exts = tuple(e if e.startswith(".") else "." + e for e in a.ext.split(",") if e.strip()) try: files = collect(a.paths, exts) except SystemExit as e: print(e) return 2 if not files: print("⛔ 一个待查文件都没扫到(给了 %s,扩展名 %s)—— 检查路径与 --ext。" % (a.paths, exts)) return 2 all_f, all_h, n_pass = [], [], 0 print("待查 %d 份(扩展名 %s)\n" % (len(files), "/".join(exts))) for p in files: fails, hints, passes = check_file(p) rel = p if fails: print("⛔ %s" % rel) for x in fails: print(" %s" % x) else: n_pass += len(passes) print("[过] %s" % rel) for x in hints: print(" [提示] %s" % x) all_f += fails all_h += hints print() print("小计:文件 %d | 通过项 %d | FAIL %d | 提示 %d" % (len(files), n_pass, len(all_f), len(all_h))) if all_f: print("⛔ 有用词 FAIL —— 交付前必须清掉(引号里的「待改对象」不在此列)。") return 1 print("✅ 用词全过。") if all_h: print("ℹ️ 提示项是「引用旧名/内部词」,人工看一眼是否确实是待改对象。") return 0 if __name__ == "__main__": sys.exit(main())