diff --git a/client/electron/scripts/gen-background-takeover.mjs b/client/electron/scripts/gen-background-takeover.mjs index 76073fb..4e281cf 100644 --- a/client/electron/scripts/gen-background-takeover.mjs +++ b/client/electron/scripts/gen-background-takeover.mjs @@ -21,7 +21,19 @@ import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const SRC = join(HERE, '..', 'src'); -const OUT = join(SRC, 'background-takeover.generated.css'); +/* + * ★ 输出路径可覆盖(`GEN_BG_OUT`),**为了让"干跑"真的只读**。 + * + * 原来这里硬编码 `src/background-takeover.generated.css`(**已跟踪文件**), + * 于是 `install.sh --check` 跑 `gen:bg` 会**原地重写被跟踪的文件** —— + * 一个自称干跑的命令改了工作树,而且顺手擦掉"有人忘了跑生成器"的证据。 + * (pi 2026-09-15:与我从 `dist` 上修掉的形状完全相同。) + * + * 有了这个开关,判据可以**重新生成到临时路径再跟真身比**(见 + * `test/background.test.mjs` 那条"生成物与源码同步"), + * ⇒ "幂等"从**观察**("这棵树恰好同步")变成**被检查的性质**。 + */ +const OUT = process.env.GEN_BG_OUT || join(SRC, 'background-takeover.generated.css'); /** 表面段:浅色大类 + 50/100/200 三档。 */ const SURFACE = /^bg-([a-z]+)-(50|100|200)$/; diff --git a/client/electron/test/background.test.mjs b/client/electron/test/background.test.mjs index 7066ee3..f1cd2e6 100644 --- a/client/electron/test/background.test.mjs +++ b/client/electron/test/background.test.mjs @@ -7,8 +7,10 @@ * 这些检查存在的理由:背景是**装饰层叠加在内容之下**,它的 bug 形态是 * 「正文读不动」和「背景根本没出现」,两者都不报错、不影响构建。 */ -import { prose } from './lib/read.mjs'; -import { readdirSync } from 'node:fs'; +import { prose, bytes } from './lib/read.mjs'; +import { readdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import postcss from 'postcss'; @@ -200,6 +202,27 @@ check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join( // 缺陷的另一半:当时只接管了 white/gray-50/slate-100,而源码里在用的 // gray-100(24 处)、blue-50(17)、red-50(13)… 全是实心的,正好把壁纸盖住。 const gen = read('../src/background-takeover.generated.css'); + + /* + * ★ 生成一次、用一个来源(`GEN_BG_OUT` 指到临时路径)——**不给自己留兄弟副本**。 + * (这一路的教训:`stripStrings` 曾经两份、`blurStyleFor` 曾经死而不删; + * 同一个事实有两份实现,下一轮只会被修在其中一份里。) + * 读**二进制原文**用 `bytes()`:这条判的正是"字节是否一致",不是"代码里有什么"。 + */ + const generateFresh = () => { + const dir = mkdtempSync(join(tmpdir(), 'agentmail-genbg-')); + const out = join(dir, 'out.css'); + try { + const r = spawnSync(process.execPath, [join(here, '../scripts/gen-background-takeover.mjs')], + { encoding: 'utf8', env: { ...process.env, GEN_BG_OUT: out } }); + if (r.status !== 0) { + return { err: `退出码 ${r.status}:${(r.stderr || '').trim().split('\n').slice(-2).join(' / ')}` }; + } + return { text: bytes(out).toString('utf8') }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; const used = new Set(); const walk = dir => { for (const e of readdirSync(dir, { withFileTypes: true })) { @@ -245,6 +268,52 @@ check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join( check('页面底没有被写进半透明清单', [...PAGE_BASE].every(b => !gen.includes(`.${b} {`))); check('清单不是空跑(至少扫到 10 个类)', used.size >= 10, `实际 ${used.size}`); + /* + * 17b) ★★ 生成物必须与**当前源码**逐字节一致 —— 把上面那条判据**两个方向都补上**。 + * + * pi 2026-09-15:上面那条 `missing.length === 0` **只判一个方向** + * ("源码里用到的类必须都在生成文件里"); + * **反向没有判据**:生成文件里**多出来的、源码已不再用**的类,谁也看不见。 + * 于是有这样一条路径: + * ① 有人删掉最后一处 `bg-blue-50` 用法、**没跑生成器**; + * ② 生成文件里留下不再需要的 `.bg-blue-50` 规则 ⇒ 上面那条**仍然绿**(它只看漏没漏); + * ③ `install.sh --check` 跑 `gen:bg` ⇒ **原地删掉那条规则** ⇒ + * 一个自称干跑的命令**改了一个被跟踪文件**,还**擦掉了"有人忘了跑生成器"的证据**。 + * + * ⇒ 判据:**重新生成到临时路径,与真身逐字节比**。它同时给出三件事: + * 反向那一半(多出来的过期规则)、"幂等"从**观察**变成**被检查的性质**、 + * 以及"忘了跑生成器"**会被判红而不是被静默修好**。 + * + * ⚠️ 只**读到**临时路径(`GEN_BG_OUT`),绝不写工作树里那个文件 —— + * 一条"判同步"的判据如果自己去同步,就永远绿。 + * ⚠️ 早先我在这里用了裸 `readFileSync`,**被本仓自己的 `criteria-hygiene` 抓红**了 + * ("判据目录里不得裸用 readFileSync")。这是它**正确工作**的一例: + * 我新写判据时没走 `test/lib/read.mjs` 的具名入口。现在读**二进制原文**用 `bytes()` + * (这条判的是"字节是否一致",不是"代码里有什么"),所以 `bytes()` 正是对的入口。 + */ + const freshGen = generateFresh(); + if (freshGen.err) { + check('生成器能跑起来(这条判据的前提)', false, freshGen.err); + } else { + const fresh = freshGen.text; + if (fresh === gen) { + check('生成物与当前源码逐字节一致(反向:也没有多出来的过期规则)', true); + } else { + const fl = fresh.split('\n'); + const gl = gen.split('\n'); + let i = 0; + while (i < Math.min(fl.length, gl.length) && fl[i] === gl[i]) i += 1; + const inFile = gl.filter(l => /^html\[data-bg='on'\] \./.test(l)).length; + const inFresh = fl.filter(l => /^html\[data-bg='on'\] \./.test(l)).length; + check('生成物与当前源码逐字节一致(反向:也没有多出来的过期规则)', false, + `真身 ${inFile} 条规则 vs 重新生成 ${inFresh} 条;首个不同在第 ${i + 1} 行:\n` + + ` 真身:${(gl[i] ?? '(无)').trim().slice(0, 90)}\n` + + ` 新生成:${(fl[i] ?? '(无)').trim().slice(0, 90)}\n` + + ' 修法:跑 `npm run gen:bg` 并**把改动提交**(别让它在某次干跑里被静默改掉)。\n' + + ' 注意这条判的是**两个方向**:漏了规则会红,**多了过期规则也会红**。'); + } + } + /* * ★ 生成产物"能被 CSS 解析器吃下"还是"只有浏览器能发现"(2026-09-14,pi 提议)。 * diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index a041eac..5803842 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -63,7 +63,7 @@ const SUITE = [ ['test/nav-merge.test.mjs', [], 8], ['test/animation-audit.test.mjs', [], 4], // 动画全量盘点:死动画/过宽作用域/弹层接线/reduced-motion ['test/theme.test.mjs', [], 30], - ['test/background.test.mjs', [], 42], + ['test/background.test.mjs', [], 43], ['test/cross-client-theme.test.mjs', [], 15], // 预设的**行为**判据:每一档都真的画得出来(能真跑,不需要设备 ⇒ 不进 static 欠账)。 // 与 appearance-defaults 那条「清单 id/顺序相等」配对:值判据管清单,行为判据管渲染器。 diff --git a/deploy/install.sh b/deploy/install.sh index e833e87..008fc51 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -171,6 +171,17 @@ fi # 实测它内容幂等(`git status` 干净),所以**不改内容**;但"写文件这个动作"仍在, # 所以"干跑只读"这句话**准确的说法是"不改任何被跟踪文件的内容、不碰 dist"**, # 不是"一个字节都不写"。我看不出的部分不写死。 +# +# ★ 而 pi 2026-09-15 指出:上面那句"不改任何被跟踪文件的内容"**当时是假的** —— +# `npm run gen:bg` 会**原地重写** `src/background-takeover.generated.css`(**已跟踪**)。 +# 他说得更准的一点是:**"幂等"只在一个方向上被保证** —— 守它的判据只判 +# "源码里用到的类必须都在生成文件里",**反向(生成物里多出来的过期规则)没有判据**。 +# 于是:有人删掉最后一处用法、忘了跑生成器 ⇒ 生成物留下一条过期规则 ⇒ 那条判据**仍绿** +# ⇒ 干跑一跑 `gen:bg` **原地删掉它** ⇒ 一个自称干跑的命令改了被跟踪文件, +# 还**擦掉了"有人忘了跑生成器"的证据**。(与 `dist` 那条形状完全相同。) +# ⇒ 现在:干跑**不再原地生成**,而是生成到临时路径,并**把漂移报出来**(不替人修 —— +# 干跑去修就等于把证据擦掉);判据那一侧已在 `background.test.mjs` 补了**双向**的 +# "生成物与源码逐字节一致"。 if [[ $CHECK_ONLY -eq 1 ]]; then npm_rc=0 # 产物写到临时目录:干跑不该动 dist(否则会静默刷掉 build-stamp 的红) @@ -179,16 +190,23 @@ if [[ $CHECK_ONLY -eq 1 ]]; then cd "$REPO/client/electron" \ && npm run typecheck \ && AGENTMAIL_CRITERIA_PHASE=install npm test \ - && npm run gen:bg \ + && GEN_BG_OUT="$CHECK_DIST/generated.css" node scripts/gen-background-takeover.mjs \ && npx vite build --outDir "$CHECK_DIST" --emptyOutDir \ && BUILD_INFO_OUT="$CHECK_DIST/BUILD_INFO.json" node scripts/build-info.mjs ) || npm_rc=$? + # ★ 生成物与真身不一致 ⇒ **报告,不修**(干跑替人修就把"有人忘了跑生成器"擦掉了) + if [[ -f "$CHECK_DIST/generated.css" ]] \ + && ! cmp -s "$CHECK_DIST/generated.css" "$REPO/client/electron/src/background-takeover.generated.css"; then + echo " [WARN] background-takeover.generated.css 与**当前源码**不一致(干跑**没有**替你改):" + echo " 修法:cd client/electron && npm run gen:bg,然后**把改动提交**。" + echo " (判据 test/background.test.mjs 会就这件事判红 —— 干跑这里只提示,不替你做。)" + fi rm -rf "$CHECK_DIST" if [[ $npm_rc -ne 0 ]]; then echo " [FAIL] 前端门禁(typecheck / 判据 / build)退出码 ${npm_rc} —— 详见上面的红" >&2 CHECK_GATE_RC="$npm_rc" else - echo " [ OK ] 前端门禁通过(构建产物落在临时目录,**没有动 dist**)" + echo " [ OK ] 前端门禁通过(构建产物与生成物都落在临时目录,**没有动 dist、也没动被跟踪的生成物**)" fi else ( cd "$REPO/client/electron" && npm run typecheck && AGENTMAIL_CRITERIA_PHASE=install npm test && npm run build )