diff --git a/client/electron/test/criteria-hygiene.test.mjs b/client/electron/test/criteria-hygiene.test.mjs index a32cd9f..807c7e6 100644 --- a/client/electron/test/criteria-hygiene.test.mjs +++ b/client/electron/test/criteria-hygiene.test.mjs @@ -24,7 +24,7 @@ import assert from 'node:assert/strict'; import { readdirSync, unlinkSync, writeFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; -import { dirname, join, relative, resolve } from 'node:path'; +import { basename, dirname, join, relative, resolve } from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; @@ -698,3 +698,82 @@ test('★ 政策门禁(deploy/check-*.sh)必须被入口脚本真正调用 + ' **不能直接调** —— 门禁红时它 `exit 1`,而 `install.sh` 是 `set -e`,\n' + ' 会在那里中止、后面所有诊断一行都不打(`install.sh` 141-157 行刚修过同一个毛病)。'); }); + +/* + * ★★ 非门禁工具的**发现路径**(pi `a6dd501c` 2026-09-25 指出,我复现)。 + * + * 上一条判据管住了 `check-*.sh`(门禁族)—— 它靠**命名约定**被强制接线。 + * 而那条约定有个代价:**为了躲开它而改名之后,就没有任何判据管"改名后还找不找得到"**。 + * 实测: + * · `recount-relay-counts.sh` —— 新名(为躲 `check-*` 约定而改)非自身引用 = **1** + * (只有 `docs/DEBTS.json`),`install.sh` / `.githooks` / `docs/DEV-TOOLING.md` 全 0 + * · `archive-stale-sessions.sh` —— 非自身引用 = **0**(全仓只命中它自己) + * 对照:同族的 `prune-deploy-artifacts.sh` 在 `docs/DEV-TOOLING.md:71` 有**专节** —— + * 那才是它会被找到的原因(发现路径存在,不靠人记得)。 + * + * ⇒ 判据: **每个非门禁 `deploy/*.sh` 至少要有一个"非自身"的引用**。 + * 与上一条**同族但机制不同**:上一条防"判据在但**走不到**"(执行路径), + * 这一条防"工具在但**没人知道它存在**"(发现路径)。 + * ⚠️ 为什么上一条盖不住它: `check-*` 靠 `readdirSync` 强制, + * 而**改名正好绕开那条约定** —— 于是"改名"这个合规动作把工具从"强制被调用" + * 直接降级成"零引用",**而且没有任何东西会红**。 + */ +test('★ 非门禁工具(deploy/*.sh)必须有发现路径(至少一处非自身引用)', () => { + const DEPLOY = join(REPO_ROOT, 'deploy'); + // 排除门禁族(上一条已管)与三个入口脚本自身(它们是入口,不是"被发现的对象") + const ENTRY_ITSELF = new Set(['install.sh', 'redeploy-gateway.sh', 'redeploy-plugin.sh']); + const tools = readdirSync(DEPLOY) + .filter((n) => n.endsWith('.sh') && !/^check-.*\.sh$/.test(n) && !ENTRY_ITSELF.has(n)) + .sort(); + + // 反空真:工具一个都没列出来 ⇒ 大概率是路径/目录变了,不是"工具真的没了" + assert.ok(tools.length >= 2, + `只找到 ${tools.length} 个非门禁 deploy/*.sh(期望 ≥2)—— 大概率是路径变了。空真必须报红。`); + + /* + * 扫描范围:可能承载"发现路径"的文本。用 `prose()`(原文)—— + * 判的是"文档/注释里有没有提到它",那**正是散文**,不是代码。 + */ + const SCAN_DIRS = ['docs', 'deploy', 'client/electron/test', '.githooks', 'scripts']; + const SCAN = SCAN_DIRS.flatMap((d) => { + const base = join(REPO_ROOT, d); + try { + return readdirSync(base, { recursive: true }) + .map((f) => join(base, String(f))) + .filter((p) => /\.(md|mjs|sh|json)$/.test(p) && !p.includes('node_modules')); + } catch { return []; } + }).filter((p) => resolve(p) !== resolve(fileURLToPath(import.meta.url))); + + /* + * ★★★ **必须排除本判据自己** —— 这一条我第一版漏了,当场假绿。 + * + * 那条注释里写着「`archive-stale-sessions.sh` —— 非自身引用 = **0**」, + * 而判据扫的是"文件里有没有出现这个名字" ⇒ **它自己那句描述**就被数成了 1 个引用 + * ⇒ 一个零引用的孤儿**因为被描述成孤儿而看起来有引用** ⇒ `refs=1` ⇒ 判绿。 + * + * 实测:排除 SELF 之前 8/8 全绿(假绿);排除之后立刻报出 `archive-stale-sessions.sh`。 + * 这与本仓已记录的那族**完全同形**:"判据开始消费散文" —— + * 判据红/绿的原因变成了**它自己怎么写这段说明**,而不是被测对象的状态。 + * ⚠️ 泛化:**任何"扫全仓找引用"的判据都必须排除观察者本身**, + * 否则"描述缺陷"与"存在引用"不可区分(这条与 `stripComments` 那条同源)。 + */ + + const orphans = []; + for (const t of tools) { + let refs = 0; + for (const p of SCAN) { + if (basename(p) === t) continue; // 排除自身 + if (prose(p).includes(t)) refs++; + } + if (refs === 0) orphans.push(t); + } + + assert.deepEqual(orphans, [], + `这些工具**没有任何发现路径**(存在,但没人会知道它存在):\n` + + ` ${orphans.join('\n ')}\n` + + ' ⇒ 药方:在 `docs/DEV-TOOLING.md` 加一节(照 `prune-deploy-artifacts.sh` 的先例),\n' + + ' 或在相关入口/工具里引用它。\n' + + ' ⚠️ 为什么要紧(实测形状):为了躲开 `check-*` 命名约定而**改名**,\n' + + ' 会让工具从"强制被调用"掉到"零引用",而**上一条判据正好不再覆盖它** ——\n' + + ' 改名这个合规动作本身制造了一个无判据的盲区。'); +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index fcdd680..9487042 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -136,8 +136,9 @@ const SUITE = [ ['test/harmony-calendar.test.mjs', ['--experimental-strip-types', '--no-warnings'], 30], ['test/debt-visibility.test.mjs', [], 1], ['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 4], - // 判据目录自身的卫生:读文本必须走 test/lib/read.mjs 的具名入口 - ['test/criteria-hygiene.test.mjs', [], 7], + // 判据目录自身的卫生:读文本必须走 test/lib/read.mjs 的具名入口; + // 以及非门禁工具必须有**发现路径**(躲开 check-* 约定改名 ⇒ 否则零引用无人知) + ['test/criteria-hygiene.test.mjs', [], 8], // 用户管理页(P4c 同批):动作↔服务端调用同名 / 门禁只认严格 admin / // 启停只发 status / 「受限」徽标口径 / 页面零写死色值 / 接线(纯逻辑真被调用) ['test/harmony-admin.test.mjs', ['--experimental-strip-types', '--no-warnings'], 31], diff --git a/docs/DEV-TOOLING.md b/docs/DEV-TOOLING.md index 472e999..925be6b 100644 --- a/docs/DEV-TOOLING.md +++ b/docs/DEV-TOOLING.md @@ -123,6 +123,34 @@ bash deploy/prune-deploy-artifacts.sh --self-check # 判据自检(16 项, ③ "自检不碰生产"进判据(前后指纹比对)。这条与 `pkill -f` 杀自己同类: **安全装置自己出错时,产出的是一份看着正常的报告**。 +## 按需工具(`deploy/` 下非门禁的那些) + +`deploy/check-*.sh` 是**门禁族**:它们靠**命名约定**被 `criteria-hygiene.test.mjs` +用 `readdirSync` 强制接线,凡该族必须被入口脚本调用(判据在、不许走不到)。 + +★ 而这条约定的**代价**是:**为了躲开它而改名之后,就没有任何判据管"改名后还找不找得到"**。 +为躲开 `check-*` 而改名的工具(`recount-*`/`prune-*`/`archive-*`/`reset-*`)属于**按需工具**: +不强制接线,但必须**有发现路径** —— 否则它存在,而没人会知道它存在。 + +判据在 `criteria-hygiene` 的「非门禁工具必须有发现路径」:每个非门禁 `deploy/*.sh` +至少要有**一处非自身引用**(文档 / 入口 / 别的工具)。实测它建起来时抓到的第一个孤儿 +就是 `archive-stale-sessions.sh`(全仓零引用)。 + +| 按需工具 | 发现路径 | +|---|---| +| `deploy/prune-deploy-artifacts.sh` | 本节上方专节 | +| `deploy/recount-relay-counts.sh` | 本节(口径复算;被 `docs/DEBTS.json` 引用) | +| `deploy/archive-stale-sessions.sh` | 本节 —— 归档陈旧会话(`--dry-run` 先看) | +| `deploy/prune-test-sessions.sh` | `docs/DEV-TOOLING.md` 的清理表、`redeploy-gateway.sh` 注释 | +| `deploy/reset-demo.sh` | `client/electron/README` 与演示脚本族 | + +⚠️ 同名判别器的**两个不同机制**(别混): +``` +check-* 族 : 「判据在但**走不到**」(**执行**路径)—— readdirSync 强制 +按需工具族 : 「工具在但**没人知道它存在**」(**发现**路径)—— 本表就是那个发现路径 +改名正好绕开前者 ⇒ 因此后者必须独立存在,不能指望前者兜住 +``` + ## 判据纪律:三种"看起来验过了"的失效形态(2026-09-14) 同一天里,三条自己写的判据各以一种方式失效 —— 它们**都绿着**,但都不再判别任何东西。