Files
workbuddy_skills/product-planning/scripts/check_terms.py
T

195 lines
9.1 KiB
Python
Raw Normal View History

#!/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())