195 lines
9.1 KiB
Python
195 lines
9.1 KiB
Python
#!/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())
|