Files
STM32F4-Base/scripts/check_generated_fresh.py
T
wandering1 842d711ed6 refactor(编号): 测试验证层级去号——CI job / 门禁输出 / 命令行旗标换语义名 + ASCII 段名
用户裁定「我不想看到编号」。测试验证层级这一族的整族清退分三道并行:本笔是**代码与 CI 道**
(CI 步骤与门禁输出面),代码注释面另走、文档面由文档道走。

口径(旧 → 语义名 → ASCII 段名,段名只进日志/旗标/job id/文件名):
  L0 静态门禁 static | L0a 钩子解释器预检 preflight | L0b 提交信息门禁 commitmsg
  L0c 体积门禁 size | L0.5 主机侧纯逻辑单测 host-unit | L0.5c 覆盖率 coverage

- scripts/ci_local.py 43 站点:段标题与 [SUMMARY] 标签改 ASCII 段名(static: /
  host-unit:),函数 run_l0/run_l05 → run_static_gates/run_host_unit_tests,
  收尾「已跑:」列表用段名。旗标改 --static-only / --host-unit-only,**旧旗标
  --l0-only / --l05-only 保留为 argparse 别名**(肌肉记忆与既有脚本片段不断档,
  实测 --help 两名列出)。
- .gitea/workflows/ci.yml 42 站点:job id l0-gates → static-gates、
  l05-host-unit-tests → host-unit-tests;步骤名与 step-summary 标题改语义名;
  日志名 l0-pre-commit.log → static-pre-commit.log、l05-<模块>.log →
  host-unit-<模块>.log;::error::/::notice:: 文案同步。yaml.safe_load 核过
  两个 job id 与步骤名。
  host-unit-tests;步骤名与 step-summary 标题改语义名;日志名 l0-pre-commit.log →
  static-pre-commit.log、l05-<模块>.log → host-unit-<模块>.log;::error::/::notice::
  文案同步。yaml.safe_load 核过两个 job id 与步骤名。
- 门禁脚本 18 站点:check_registration(登记簿理由串 7)、check_test_coverage 5、
  README 8、check_generated_fresh 2、ram_baseline 2、check_size_gate 1、
  ci_find_python.ps1 2、ci_runner_bootstrap.ps1 2、.pre-commit-config.yaml 2、
  requirements.txt 1(保持纯 ASCII)。
- skills 7 站点:task-worktree(含 [SUMMARY] 样例两行)、mem-budget、ui-verify、
  task_worktree.py 的实跑标签。
- AGENTS.md 3 站点:CI 段「只跑静态门禁 + 主机侧纯逻辑单测」、自查命令段、
  「2026-10-11 静态门禁全灭」事件名。
- 归档:编号治理历史.md 新增 §10 换算表 + **撞族警示**(同形 L0/L1/L2/L3 还属 UI 设计
  令牌层 / UI 菜单层级 / 采集链 / 链路时延段 / CubeMX 三层 / 硬件位号与 FTP 应答字面量
  等六个族,其中 6 处在字符串字面量里,改了就是改行为——故全程只按行内置换、先判族)。
- docs/产品需求 §7.4 测试分层表:去号 + 顺带修掉上一批**错族替换疤**(该表 L1/L2 行曾被
  采集链层名「语义取数与有效性 / 全周傅氏相量」「保护判据」顶替,现按内容改写为板端测试
  套件 / 主机侧回归两行)。同文档 §1.5 G5 判据行同步。

门禁:ci_local --help 两面旗标并列;py_compile 7 个 .py 全过;yaml 解析通过;
check_ids_new --worktree rc=0;git diff --check 干净;17 个改动文件 FFFD=0、无 CR、
BOM 与原文件一致(含 requirements.txt 纯 ASCII)。本族无机器识别面(单字母+数字是
新增行门禁的自述盲区),口径来自自建逐行扫描器,复扫本族形态归零。
- 交接说明:文档道 teammate 在改 CI 指南时中断,剩余 9 处双写疤与 29 处 CI 标识
  (job id / 日志名 / 汇总行字面量)由 Lead 接手改完,逐处仍走断言式行内置换。
2026-10-28 17:46:52 +08:00

664 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""check_generated_fresh.py — 生成器幂等性门禁(重跑到临时目录 → 与工作区逐字节一致)。
背景(实证,不是预防性):
0f7b5a87(编号迁移)改了 tools/menu_gen/gen_menu.py 的**模板文字**却没重跑生成物
→ HEAD 里 Drivers/BSP/UI/ui_menu_tab.h 与模板不一致,直到后来有人重跑生成器才被
顺带纠正(属主确认以生成器输出为准)。本钩子接线前实测又扫出 **5 张 docs/images/*.svg**
与各自的生成器不同码移动——全是文档挪目录后 rename_ref.py 改了 tools/ 里的引用路径、
产物没跟着重跑(逐条见 BASELINE)。所以"生成物比生成器旧"是**系统性**缺口,不是偶发。
它的隐蔽之处在于:改生成器的人看不到任何 diff(脚本改动编译与单测全过),下一个
**碰巧**重跑生成器的人却会看到一段"不是我改的"改动 → 要么连带提交、要么 revert 掉真修复。
只读承诺(绝不写仓库):
重跑一律在 tempfile.mkdtemp()(系统临时目录)里进行。三个生成器都用
os.path.dirname(__file__) 的三级父目录定位仓库根,因此把**脚本自身**拷进 temp 后,
它写出的每个文件都落在 temp:工作区一个字节都不动,不需要还原,也不会 stash
别人在飞的改动(同一条钩子因此可以安全地跑在多人共用的工作树上)。
判定口径:
每个生成器声明 seed(按相对路径拷进 temp 的输入)与 mkdir(生成器不会自建、
但需要存在的输出目录)。跑完遍历 temp 里除 __pycache__ 外的每个文件,与工作区
同路径逐字节比对:
DIFF 工作区有、内容不同 → 生成物比生成器旧 → FAIL
NEW 工作区没有该文件 → 重跑会凭空多出文件(漏注册)→ FAIL
rc≠0 生成器自身报错 → 打印 stderr 末行 → FAIL
唯一例外是 ui_cn_chars.txt:按 font-regen 约定它 = 源码在用 ∪ KEEP_EXTRA(刻意保留
字),直接字节比对必然不等,故改判"txt 恰等于 重跑结果 ∪ KEEP_EXTRA",两个方向都不
许差——差一个字,下次重跑就会冒出或洗掉一段"不是我改的"改动。KEEP_EXTRA 用 importlib
直载 scripts/check_font_charset.py,**不抄第二份清单**(两套清单必然漂移,
同 docs/代码规范/pre-commit使用说明.md §5.5 的教训)。
存量基线 BASELINE(只减不增,同构 layer-deps 的 BASELINE / registration-check 的 KNOWN_GAPS):
接线当天(2026-10-26)实测 5 张 SVG 已漂 → 钉进基线判 WARN,避免钩子一上线就天天红。
还清一条就删一条:默认运行会点名"已还清仍在册",--prune 打印每条的出处。
⚠ 基线**只覆盖 docs/images/*.svg**。业务生成物(ui_menu_tab.* / ui_page_registry.* /
ui_font_cn_12.c / 双端注册表)一条基线都没有:漂了就是 FAIL,没有豁免面可钻。
解释器解析(2026-10-26 真实提交里实测到的假红,同日修):
钩子 entry 是裸 python,而 pre-commit 在提交环境里把它解析成了**机器级** C:/Python314——那上面
没装 fontTools,于是那次提交实测报:
[FAIL] cn-font tools/font_gen/make_font.py 退出码 1:ERROR: No module named 'fontTools'
纯标准库的 menu-gen / cn-chars 照旧绿,所以这是一根**只在字库链上假红**的闸:换任何一台没把
.venv 排在 PATH 前面的机器都会重犯(同 scripts/ci_local.py 文件头 2026-10-11 记的静态门禁全灭)。
修法:脚本自己按优先级挑解释器(显式 DTU_GEN_PYTHON > 当前解释器 > 仓库 .venv > PATH 探到的
机器级)**且逐个探针校验**(要真能 import fontTools 才算数),整轮生成器统一用挑出来的这一个
跑(换解释器就可能换输出格式,逐字节比对要的是同一把尺子);一个都挑不出来时打 [ENV] + 候选
探针表 + 修 var / 重建 .venv 的指引并**退出码 2**,不静默 SKIP。
环境依赖与 SKIP:
make_font.py 需要 node + 全局 lv_font_conv + Windows 宋体 simsun.ttc 与
Segoe UI Symbol seguisym.ttf(补 ▶ 箭头字形用)。缺任一项 → 该项 SKIP 并打印
[SKIP],默认退出码 0,--strict-env 升级为 FAIL。为什么这里允许 SKIP 而 test-coverage
选 FAIL:字体链的产物在缺工具的机器上根本无法生成,FAIL 只是把人挡在提交门外、
没有任何可执行的修法;SKIP 至少把"这台机器没验过"这件事留在输出里(盲区同时登记在
docs/门禁说明/README.md 的索引行)。
用法(仓库根目录执行,python 一律用 .venv):
& '.venv\Scripts\python.exe' scripts/check_generated_fresh.py
追加开关:--list 只列声明不跑 / --only id[,id] 只跑指定项 / --verbose 打印差异行 /
--strict-env 把 SKIP 当 FAIL / --selftest 自证有牙 / --prune 打印基线台账 /
--keep-temp 保留临时目录供排查(默认删除)
退出码:
0 = 一致(含 SKIP 与存量基线 WARN)
1 = 新漂移 DIFF / NEW / 生成器报错或超时 / 自测证明判定失效
2 = 工具或环境坏了(仓库根定位失败、KEEP_EXTRA 载入失败、--only 写了不存在的 id、
挑不出能跑字体链的解释器——后者会打候选探针表与修法,不静默 SKIP)
——与"生成物漂了"分开,好让人一眼看出该修代码还是该修环境
创建日期:2026-10-26(task-18;实证 = 0f7b5a87 改模板没重跑 + 接线前实测 5 张 SVG 已漂)
"""
import argparse
import difflib
import glob
import importlib.util
import os
import shutil
import subprocess
import sys
import tempfile
import time
try:
sys.stdout.reconfigure(errors="replace") # GBK 控制台打不出 ▶ 等符号
sys.stderr.reconfigure(errors="replace")
except Exception:
pass
REPO = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
PY = sys.executable
RUN_TIMEOUT = 300 # 单生成器上限(秒);字体链实测 < 5 s
KEEP_SRC = "scripts/check_font_charset.py" # KEEP_EXTRA 的唯一事实源(importlib 直载)
# 跑生成器的解释器(argv 前缀)。默认就是跑本钩子这个;main() 里按下面的优先级解析后可能被换掉
# (见 interpreter_candidates)。2026-10-26 假红事故:钩子 entry 是裸 python,pre-commit 在提交
# 环境里把它解析成机器级 C:/Python314,那上面没装 fontTools → 字体链报 No module named fontTools。
RUN_PY = [sys.executable]
# 存量基线:接线当天实测已漂的产物,**只减不增**(还清即删整条,--prune 打印出处)
BASELINE = (
("docs/images/项目架构总览.svg", "tools/arch_diagram/gen_arch_svg.py",
"脚注写 docs/项目架构总览.md,生成器已改成 docs/架构设计/(文档挪目录,产物没重跑)"),
("docs/images/配置文件分层架构.svg", "tools/arch_diagram/gen_cfg_arch_svg.py",
"图内写 App/cfg_file.c,生成器已改成 App/cfg/cfg_file.c(配置引擎挪目录,产物没重跑)"),
("docs/images/RemoDispBus帧格式.svg", "tools/arch_diagram/gen_frame_svg.py",
"脚注写 docs/RemoDispBus协议与接收设计说明.md,生成器已改成 docs/UI设计/(挪目录)"),
("docs/images/物理分区布局.svg", "tools/arch_diagram/gen_partition_svg.py",
"脚注写 docs/STM32F4-Base存储架构说明.md,生成器已改成 docs/架构设计/(挪目录)"),
("docs/images/存储栈分层.svg", "tools/arch_diagram/gen_storage_stack_svg.py",
"同上:存储架构说明挪进 docs/架构设计/,产物脚注没重跑"),
)
GENS = (
{
"id": "menu-gen",
"what": "菜单节点表 + 页面注册表(并回写双端注册表)",
"script": "tools/menu_gen/gen_menu.py",
# gen_menu 会 os.listdir(Drivers/BSP/UI) 找页面实例并按 #page 绑定校验,
# 所以整个 UI 目录 + 两份注册表都是它的输入,必须一起拷进 temp
"seed": ["ui_menu_tree.txt", "Drivers/BSP/UI",
"MDK-ARM/STM32F407-Demo.uvprojx", "simulator/CMakeLists.txt"],
"mkdir": ["Drivers/BSP/UI"],
"mode": "exact",
},
{
"id": "cn-chars",
"what": "UI 字符集清单 ui_cn_chars.txt(字体链第 2 步)",
"script": "tools/font_gen/extract_cn.py",
"seed": ["Drivers/BSP/UI", "App/task/ui_task.c", "App/task/ui_task.h"],
"mkdir": [],
"mode": "chars-union-keepextra",
"artifact": "ui_cn_chars.txt",
},
{
"id": "cn-font",
"what": "中文子集字库 ui_font_cn_12.c(字体链第 3 步)",
"script": "tools/font_gen/make_font.py",
"seed": ["ui_cn_chars.txt"],
"mkdir": ["Drivers/BSP/UI"],
"mode": "exact",
"requires": "font_chain",
"third_party": True, # make_font 要 fontTools(从 simsun.ttc 抽 face0)
},
{
"id": "docs-svg",
"what": "docs/images 精确布局配图(每个生成器各开一个临时目录)",
"per_script": True,
"dir": "tools/arch_diagram",
"pattern": "gen_*.py",
"seed": [],
"mkdir": ["docs/images"],
"mode": "exact",
"has_baseline": True,
},
)
drifting_now = set() # 本轮实测仍在漂的基线条目(用于"可下架"提示)
def ws_path(rel):
"""仓库根相对路径(声明里一律用正斜杠)→ 本机绝对路径。"""
return os.path.join(REPO, *rel.split("/"))
# --- 解释器解析(2026-10-26 假红事故的修法;优先级复刻 scripts/ci_local.py)---------------
# 显式变量 DTU_GEN_PYTHON > 当前解释器 > 仓库 .venv > PATH 上探到的机器级,并且**必须探针校验**
# 候选真的跑得动(装了 pre-commit ≠ 装了 fontTools;Microsoft Store 的 python 占位程序更是跑不起
# 来)。为什么不能只把钩子 entry 写死成 .venv 里的解释器:ci_local.py 文件头记着 2026-10-11 那次
# 「workflow 把候选收窄成 PATH python + 仓库 .venv,runner 上两者都是死路,静态门禁全灭」——收窄候选
# 等于把门禁绑死在一台机器上,本处保留全部候选 + 逐个探针,就是为了不再犯第三次。
PROBE_CODE = (
"import sys\n"
"try:\n"
" import fontTools\n"
" ft = str(getattr(fontTools, 'version', 'yes'))\n"
"except Exception:\n"
" ft = ''\n"
"print('%d.%d.%d|%s' % (sys.version_info[0], sys.version_info[1], sys.version_info[2], ft))\n"
)
def interpreter_candidates(injected=()):
"""按优先级给出候选解释器 → [(标签, argv 前缀)];injected 供 --selftest 塞假解释器。"""
cands = []
explicit = os.environ.get("DTU_GEN_PYTHON")
if explicit:
cands.append(("显式变量 DTU_GEN_PYTHON", [explicit]))
cands.append(("当前解释器(跑本钩子这个)", [sys.executable]))
for rel, label in ((".venv/Scripts/python.exe", "仓库 .venv(Scripts)"),
(".venv/bin/python", "仓库 .venv(bin)")):
path = ws_path(rel)
if os.path.exists(path):
cands.append((label, [path]))
for name in ("python", "python3"):
found = shutil.which(name)
if found:
cands.append(("PATH 上的 " + name, [found]))
seen, uniq = set(), []
for label, argv in cands:
key = os.path.normcase(os.path.abspath(argv[0])) + "|" + " ".join(argv[1:])
if key in seen:
continue
seen.add(key)
uniq.append((label, argv))
return uniq + list(injected)
_probe_cache = {}
def probe_interpreter(argv):
"""探针 → {"version": "3.12.4", "fonttools": "4.51.0"};这个解释器跑不起来则 None。"""
key = " ".join(argv)
if key in _probe_cache:
return _probe_cache[key]
info = None
try:
r = subprocess.run(list(argv) + ["-c", PROBE_CODE], capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=120)
if r.returncode == 0 and "|" in (r.stdout or ""):
ver, _, ft = r.stdout.strip().partition("|")
info = {"version": ver.strip(), "fonttools": ft.strip()}
except Exception:
info = None
_probe_cache[key] = info
return info
def pick_interpreter(need_third_party, candidates=None):
"""→ (chosen|None, 候选表)。chosen = 优先级最高且满足需求的那个。"""
table, chosen = [], None
for label, argv in (interpreter_candidates() if candidates is None else candidates):
info = probe_interpreter(argv)
table.append((label, argv, info))
if chosen is None and info and (not need_third_party or info["fonttools"]):
chosen = (label, argv, info)
return chosen, table
def report_no_interpreter(table):
"""挑不出能跑字体链的解释器:逐个候选打印探针结果 + 修 var / 重建 .venv 的指引。退出码 2。"""
print(" [ENV] 没有能跑字体链的解释器(需要第三方库 fontTools)。逐个候选探针结果:")
for label, argv, info in table:
if info is None:
print(" ✗ %-44s %-24s 跑不起来(Microsoft Store 占位程序 / 已删除 / 权限)"
% (argv[0], label))
else:
print(" %s %-44s %-24s %s fontTools=%s"
% ("✓" if info["fonttools"] else "✗", argv[0], label, info["version"],
info["fonttools"] or "无"))
venv = os.path.join(REPO, ".venv", "Scripts", "python.exe")
q = chr(39)
print(" 修法(任一,改完直接重跑本脚本自检):")
print(" 1) 显式指定:PowerShell 里 $env:DTU_GEN_PYTHON = " + q + venv + q)
print(" 2) 重建仓库虚拟环境(口径 AGENTS.md「Python 环境」):")
print(" & <本机任一 python ≥3.10> -m venv .venv ; & " + q + venv + q
+ " -m pip install -r requirements.txt")
print(" ⚠ 这一项刻意**不 SKIP**:解释器或依赖坏了必须响,静默跳过等于造一根会假绿的闸")
print(" (同 test-coverage 缺 mscoverage 必 FAIL 的用户裁定精神)。")
return 2
def load_keep_extra():
"""importlib 直载 check_font_charset.KEEP_EXTRA,杜绝第二份保留字清单。"""
src = ws_path(KEEP_SRC)
if not os.path.exists(src):
raise IOError("找不到 KEEP_EXTRA 事实源: " + KEEP_SRC)
spec = importlib.util.spec_from_file_location("_keep_extra_src", src)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
keep = set(getattr(mod, "KEEP_EXTRA"))
if not keep:
raise ValueError("KEEP_EXTRA 载入后为空,判据不可能成立")
return keep
def env_font_chain():
"""字体链环境自检 → (ok, 缺什么)。"""
missing = []
if not shutil.which("node"):
missing.append("node 不在 PATH")
try:
groot = subprocess.run(["npm", "root", "-g"], capture_output=True,
text=True, timeout=60).stdout.strip()
except Exception:
groot = ""
has_conv = bool(groot) and os.path.exists(
os.path.join(groot, "lv_font_conv", "lv_font_conv.js"))
has_conv = has_conv or bool(shutil.which("lv_font_conv")
or shutil.which("lv_font_conv.cmd"))
if not has_conv:
missing.append("lv_font_conv(npm i -g lv_font_conv)")
windir = os.environ.get("WINDIR") or "C:/Windows"
for fn in ("simsun.ttc", "seguisym.ttf"):
if not os.path.exists(os.path.join(windir, "Fonts", fn)):
missing.append("系统字体 " + fn)
return (not missing), "、".join(missing)
def copy_in(tmp, rel):
s = ws_path(rel)
if not os.path.exists(s):
raise IOError("声明的输入不存在: " + rel)
d = os.path.join(tmp, *rel.split("/"))
os.makedirs(os.path.dirname(d) or tmp, exist_ok=True)
if os.path.isdir(s):
shutil.copytree(s, d, dirs_exist_ok=True,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
else:
shutil.copy2(s, d)
def run_gen(tmp, script_rel):
"""在临时目录里重跑生成器;解释器 = RUN_PY(解析优先级见 interpreter_candidates)。"""
cmd = list(RUN_PY) + [os.path.join(tmp, *script_rel.split("/"))]
return subprocess.run(cmd, cwd=tmp, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=RUN_TIMEOUT)
def tree_compare(tmp):
"""temp 树 vs 工作区 → (DIFF 列表, NEW 列表)。"""
diffs, news = [], []
for root, dirs, files in os.walk(tmp):
dirs[:] = [d for d in dirs if d != "__pycache__"]
for fn in files:
fp = os.path.join(root, fn)
rel = os.path.relpath(fp, tmp).replace(os.sep, "/")
ws = ws_path(rel)
if not os.path.exists(ws):
news.append(rel)
elif open(fp, "rb").read() != open(ws, "rb").read():
diffs.append(rel)
return diffs, news
def preview(rel, tmp, limit=8):
a = open(ws_path(rel), encoding="utf-8", errors="replace").read().splitlines()
b = open(os.path.join(tmp, *rel.split("/")), encoding="utf-8",
errors="replace").read().splitlines()
d = list(difflib.unified_diff(a, b, "工作区 " + rel, "重跑 " + rel, lineterm="", n=0))
for line in d[2:2 + limit]:
print(" " + line[:200])
if len(d) - 2 > limit:
print(" … 其余 %d 行略" % (len(d) - 2 - limit))
def fix_hint(script_rel):
print(" 修法:重跑该生成器 —— \"%s\" %s"
",然后把生成物与生成器**同一笔**提交(口径 docs/文档规范.md §4.4)" % (" ".join(RUN_PY), script_rel))
def check_exact(tmp, script_rel, base_map, verbose):
"""跑生成器 + 整树比对 → (state, msg, 涉及文件)。state ∈ OK/WARN/FAIL/ERR。"""
try:
r = run_gen(tmp, script_rel)
except subprocess.TimeoutExpired:
return "ERR", "%s 超过 %d s 未结束(生成器卡死?)" % (script_rel, RUN_TIMEOUT), []
if r.returncode != 0:
tail = ((r.stderr or "").strip().splitlines() or ["(无 stderr 输出)"])[-1][:180]
return "ERR", "%s 退出码 %s:%s" % (script_rel, r.returncode, tail), []
diffs, news = tree_compare(tmp)
if news:
return "FAIL", "%s 重跑会新增工作区没有的文件: %s" % (
script_rel, ", ".join(sorted(news)[:6])), news
hard = sorted(d for d in diffs if d not in base_map)
held = sorted(d for d in diffs if d in base_map)
if hard:
for d in hard[:3]:
print(" ---- 漂移文件 %s(生成器 %s)" % (d, script_rel))
preview(d, tmp, 6 if verbose else 2)
if not verbose:
print(" (--verbose 打更长的差异行)")
return "FAIL", "%d 个生成物比生成器旧: %s" % (len(hard), ", ".join(hard[:4])), hard
if held:
return "WARN", "%d 个存量基线在册: %s" % (len(held), ", ".join(held)), held
n = sum(len(files) for root, dirs, files in os.walk(tmp)
if "__pycache__" not in root.replace(os.sep, "/").split("/"))
return "OK", "%d 个文件逐字节一致" % n, []
def check_chars(tmp, gen, keep_extra, verbose):
"""ui_cn_chars.txt 专用:txt 必须恰等于 重跑结果 ∪ KEEP_EXTRA。"""
try:
r = run_gen(tmp, gen["script"])
except subprocess.TimeoutExpired:
return "ERR", "extract_cn 超时", []
if r.returncode != 0:
tail = ((r.stderr or "").strip().splitlines() or ["(无 stderr 输出)"])[-1][:180]
return "ERR", "extract_cn 退出码 %s:%s" % (r.returncode, tail), []
gen_txt = os.path.join(tmp, "ui_cn_chars.txt")
ws_txt = ws_path(gen["artifact"])
if not (os.path.exists(gen_txt) and os.path.exists(ws_txt)):
return "ERR", "字符集清单缺失(重跑侧或工作区侧)", []
re_chars = set(open(gen_txt, encoding="utf-8").read().strip())
ws_chars = set(open(ws_txt, encoding="utf-8").read().strip())
expected = re_chars | keep_extra
gap = sorted(expected - ws_chars) # 下次重跑会加进 txt
red = sorted(ws_chars - expected) # 下次重跑会洗掉 txt
detail = "源码在用 %d ∪ KEEP_EXTRA %d = %d,清单 %d" % (
len(re_chars), len(keep_extra), len(expected), len(ws_chars))
if gap or red:
if gap:
print(" [GAP] 清单缺 %d 字(下次重跑会加进来 → 「不是我改的」diff): %s"
% (len(gap), "".join(gap)))
if red:
print(" [REDUNDANT] 清单多 %d 字(下次重跑会洗掉;要么登记进 KEEP_EXTRA"
" 要么连字体一起重跑): %s" % (len(red), "".join(red)))
fix_hint(gen["script"])
return "FAIL", "txt 与生成链不等(%s)" % detail, gap + red
return "OK", detail + ",两向都无差", []
def new_tmp(tag):
return tempfile.mkdtemp(prefix="genfresh_" + tag + "_")
def drop(tmp, keep):
if not keep:
shutil.rmtree(tmp, ignore_errors=True)
def seed_gen(tmp, gen, script_rel):
for rel in gen["seed"]:
copy_in(tmp, rel)
for d in gen.get("mkdir", []):
os.makedirs(os.path.join(tmp, *d.split("/")), exist_ok=True)
copy_in(tmp, script_rel)
def run_one_gen(gen, base_map, args, keep_extra):
"""单项生成器(非 per_script)→ (state, msg)。"""
tmp = new_tmp(gen["id"])
try:
seed_gen(tmp, gen, gen["script"])
if gen["mode"] == "chars-union-keepextra":
state, msg, bad = check_chars(tmp, gen, keep_extra, args.verbose)
else:
state, msg, bad = check_exact(tmp, gen["script"], base_map, args.verbose)
for b in bad:
if b in base_map:
drifting_now.add(b)
return state, msg
except Exception as e:
return "ERR", "%s: %s" % (type(e).__name__, e)
finally:
drop(tmp, args.keep_temp)
def run_family(gen, base_map, args):
"""一个目录里每个生成器各开一个临时目录(互不污染)→ 汇总成一条结果。"""
scripts = sorted(glob.glob(os.path.join(ws_path(gen["dir"]), gen["pattern"])))
if not scripts:
return "ERR", "没找到生成器: " + gen["dir"], 0
ok = warn = 0
bad_msgs = []
for s in scripts:
rel_script = os.path.relpath(s, REPO).replace(os.sep, "/")
tmp = new_tmp(gen["id"])
try:
seed_gen(tmp, gen, rel_script)
state, msg, bad = check_exact(tmp, rel_script, base_map, args.verbose)
if state == "OK":
ok += 1
elif state == "WARN":
warn += 1
for b in bad:
script, why = base_map[b]
drifting_now.add(b)
print(" [BASELINE] %s <- %s:%s" % (b, script, why))
if args.verbose:
preview(b, tmp, 4)
fix_hint(script)
else:
bad_msgs.append("%s: %s" % (os.path.basename(s), msg))
except Exception as e:
bad_msgs.append("%s: %s: %s" % (os.path.basename(s), type(e).__name__, e))
finally:
drop(tmp, args.keep_temp)
if bad_msgs:
return "FAIL", "; ".join(bad_msgs[:3]) + (
"…共 %d 项" % len(bad_msgs) if len(bad_msgs) > 3 else ""), len(scripts)
return "WARN" if warn else "OK", "%d 个生成器:%d 一致 / %d 存量基线" % (
len(scripts), ok, warn), len(scripts)
def selftest(args):
"""两项自证(全在临时目录,不碰仓库):① 改一行模板必须报 DIFF;② 拿一个跑得起但没装
fontTools 的「假解释器」去要字体链,必须拿到可诊断的 [ENV] 报错(退出码 2)而不是崩溃。"""
return _selftest_drift(args) and _selftest_bad_interpreter()
def _selftest_bad_interpreter():
fake = [sys.executable, "-S"] # -S 关掉 site-packages:跑得起,但看不见第三方库
info = probe_interpreter(fake)
if not info or info["fonttools"]:
print(" ② 假解释器探针不合预期(%s)→ 这项跳过(不算失败,本机环境特殊)" % (info,))
return True
chosen, table = pick_interpreter(True, candidates=[
("自测注入:假解释器(-S 关 site-packages)", fake)])
code = 0 if chosen else report_no_interpreter(table)
print(" ② 探针:只要 fontTools 而候选全是假解释器 → chosen=%s、退出码=%s(要求:无 + 2)"
% ("有" if chosen else "无", code))
return chosen is None and code == 2
def _selftest_drift(args):
"""①:把 gen_menu.py 拷进临时目录、改掉一行模板文字 → 期望钩子报 DIFF(证明判定真的在跑)。"""
gen = next(g for g in GENS if g["id"] == "menu-gen")
probe = "bool ui_menu_tab_has_children(uint8_t node);"
tmp = new_tmp("selftest")
try:
seed_gen(tmp, gen, gen["script"])
sp = os.path.join(tmp, *gen["script"].split("/"))
src = open(sp, encoding="utf-8").read()
if probe not in src:
print(" [SELFTEST] 探针字符串已不在脚本里 → 自测本身失效,请更新 selftest()")
return False
open(sp, "w", encoding="utf-8", newline="").write(
src.replace(probe, probe.replace("has_children", "has_children_ZZ"), 1))
state, msg, _ = check_exact(tmp, gen["script"], {}, args.verbose)
print(" 探针:临时副本改 1 行模板 → 钩子判 %s(%s)" % (state, msg[:80]))
return state == "FAIL"
except Exception as e:
print(" [SELFTEST] 异常 %s: %s" % (type(e).__name__, e))
return False
finally:
drop(tmp, False)
def main():
ap = argparse.ArgumentParser(description="生成器幂等性门禁(重跑到临时目录 → 与工作区逐字节一致)")
ap.add_argument("--list", action="store_true", help="只打印生成器声明")
ap.add_argument("--only", default="", help="只跑这些 id(逗号分隔)")
ap.add_argument("--verbose", action="store_true", help="打印差异行")
ap.add_argument("--strict-env", action="store_true", help="环境缺失从 SKIP 升级为 FAIL")
ap.add_argument("--selftest", action="store_true", help="自证有牙(全在临时目录,不碰仓库)")
ap.add_argument("--prune", action="store_true", help="打印基线台账与已还清条目")
ap.add_argument("--keep-temp", action="store_true", help="保留临时目录(默认删除)")
args = ap.parse_args()
if not os.path.exists(ws_path(".pre-commit-config.yaml")):
print("[ENV] 仓库根定位失败: " + REPO)
return 2
want = [x.strip() for x in args.only.split(",") if x.strip()]
if want:
unknown = sorted(set(want) - set(g["id"] for g in GENS))
if unknown:
print("[USAGE] --only 里没有这个 id: %s(可选 %s)"
% (", ".join(unknown), ", ".join(g["id"] for g in GENS)))
return 2
gens = [g for g in GENS if not want or g["id"] in want]
if args.list:
print("=== 生成器幂等性:在册声明 %d 项(判定 = 重跑到临时目录后与工作区逐字节一致)===" % len(GENS))
for g in GENS:
src = g.get("script") or ("%s/%s(逐个)" % (g["dir"], g["pattern"]))
print(" %-10s %s" % (g["id"], g["what"]))
print(" 生成器 %-40s 输入 %s" % (src, ", ".join(g["seed"]) or "(仅脚本自身)"))
print(" 存量基线 %d 条(只减不增,--prune 看台账)" % len(BASELINE))
return 0
print("=== 生成器幂等性门禁 check_generated_fresh.py(重跑生成物 diff 必须为空)===")
print(" 仓库根 %s" % REPO)
print(" 钩子自身跑在 %s / 临时根 %s" % (sys.executable, tempfile.gettempdir()))
# 生成器解释器:整轮统一用同一个(换解释器可能换输出格式,逐字节比对要同一把尺子),
# 且必须探针校验——只有 node/lv_font_conv 都在时字体链才真的需要第三方库。
font_env = env_font_chain() if any(g.get("requires") == "font_chain" for g in gens) else None
need_ft = bool(font_env and font_env[0] and any(g.get("third_party") for g in gens))
chosen, table = pick_interpreter(need_ft)
if chosen is None:
return report_no_interpreter(table)
RUN_PY[:] = chosen[1]
print(" 生成器解释器:%s(%s,%s%s)" % (
" ".join(chosen[1]), chosen[0], chosen[2]["version"],
",fontTools " + chosen[2]["fonttools"] if chosen[2]["fonttools"] else ""))
if need_ft:
own = probe_interpreter([sys.executable])
if own is None or not own["fonttools"]:
print(" [WARN] 钩子 entry 是裸 python,被解析到了没有 fontTools 的解释器(%s)→"
" 生成器已改用上面那个。原因与实测证据 docs/门禁说明/generated-fresh.md §4"
% (sys.executable))
keep_extra = None
if any(g["mode"] == "chars-union-keepextra" for g in gens):
try:
keep_extra = load_keep_extra()
except Exception as e:
print("[ENV] 载入 KEEP_EXTRA 失败(%s: %s)→ 字符集那一项目前无法判定" % (type(e).__name__, e))
return 2
n_ok = n_warn = n_fail = n_skip = 0
fails = []
if args.selftest:
print("\n--- 自证有牙(临时副本里改模板,不碰仓库)---")
if selftest(args):
print(" [SELFTEST] PASS:改模板必被点名 + 坏解释器给出可诊断错误 → 判定与自检都活着")
n_ok += 1
else:
print(" [SELFTEST] FAIL:两项自测有没过 → 别信这根闸,先修钩子")
n_fail += 1
fails.append(("selftest", "模板漂移探针或假解释器诊断有一项没过"))
for gen in gens:
t0 = time.time()
if gen.get("requires") == "font_chain":
ok, why = font_env if font_env else env_font_chain()
if not ok:
if args.strict_env:
print(" [FAIL] %-9s 环境缺失:%s" % (gen["id"], why))
n_fail += 1
fails.append((gen["id"], "缺 " + why))
else:
print(" [SKIP] %-9s 环境缺失:%s → 本台未验(--strict-env 可升级为 FAIL)"
% (gen["id"], why))
n_skip += 1
continue
base_map = {}
if gen.get("has_baseline") and (not want or gen["id"] in want):
base_map = dict((rel, (script, why)) for rel, script, why in BASELINE)
if gen.get("per_script"):
state, msg, _n = run_family(gen, base_map, args)
else:
state, msg = run_one_gen(gen, base_map, args, keep_extra)
print(" [%s] %-9s %5.1fs %s" % (
{"OK": "OK ", "WARN": "WARN", "FAIL": "FAIL", "ERR": "FAIL"}[state],
gen["id"], time.time() - t0, msg))
if state == "OK":
n_ok += 1
elif state == "WARN":
n_warn += 1
else:
n_fail += 1
fails.append((gen["id"], msg))
cleared = [rel for rel, _s, _w in BASELINE if rel not in drifting_now]
if cleared and not want:
print(" [TIGHTEN] 基线 %d/%d 条本轮实测已还清,可下架(--prune 打出台账): %s"
% (len(cleared), len(BASELINE), ", ".join(os.path.basename(x) for x in cleared)))
if args.prune:
print("\n--- 存量基线台账(只减不增)---")
for rel, script, why in BASELINE:
print(" %-34s <- %-40s %s%s" % (rel, script, why,
"" if rel in drifting_now else " 【已还清,可删这一行】"))
print("汇总:%d 项 / 一致 %d / 新漂移或报错 %d / SKIP %d / 存量基线在册 %d 处(走 WARN 不拦提交)"
% (len(gens) + (1 if args.selftest else 0), n_ok, n_fail, n_skip, len(drifting_now)))
if n_fail:
print("结论: FAIL —— 生成物与它的生成器不同码移动。修法:重跑对应生成器,"
"产物与生成器**同一笔**提交(docs/文档规范.md §4.4)")
for gid, msg in fails:
print(" - %s: %s" % (gid, msg[:160]))
return 1
print("结论: PASS —— 在册生成器重跑后与工作区逐字节一致;存量基线在册 %d 处走 WARN 不拦提交%s"
% (len(drifting_now),
";SKIP %d 项 = 这台机器**没验过**那部分(不是验过了)" % n_skip if n_skip else ""))
return 0
if __name__ == "__main__":
sys.exit(main())