From 239f919b718a9defe6bdfe53a873a0bdc51c1bbd Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 15 Sep 2026 12:55:25 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20`unreported`=20=E5=8F=8C?= =?UTF-8?q?=E8=AE=A1=EF=BC=88=E6=88=91=E4=B8=8A=E8=BD=AE=E8=AF=B4=E4=BF=AE?= =?UTF-8?q?=E4=BA=86=E3=80=81=E4=BB=A3=E7=A0=81=E6=B2=A1=EF=BC=89+=20`chec?= =?UTF-8?q?ks`/`pass`=20=E5=8D=95=E4=BD=8D=E5=8F=96=E9=94=99=EF=BC=88?= =?UTF-8?q?=E5=B0=91=E7=AE=97=E5=A4=B1=E8=B4=A5=E6=9D=A1=E6=95=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 2026-09-15 复核后指出总数行上**还有两个活的**,两条都对,其中一条是**我上一封说修了、 代码里没修**。 ## 一、`unreportedFiles += 1` 仍在两处 —— 我说修了,没修 ``` 317 else { unreportedFiles += 1; } ← 分支之外(位置对) 354 if (ran === null) { unreportedFiles += 1; … } ← 绿色 else 里,**没删掉** ``` 一个"exit 0 且不自报条数"的文件:`ran === null` ⇒ 第一块走 `else`(+1),接着进绿色 `else` ⇒ 再走 `if (ran === null)`(+1)⇒ **计两次**。 ★ **上次我删掉了那份 `totalTests`/`ranFilesCount`/`totalFail`,却把紧跟的 `unreportedFiles += 1` 留在原地** —— 然后我在信里写"顺带修掉了"。 **"说修了"与"修了"是两件事,而只有后者可复核**。这是同一个形状的**第三次** (搬动/新增时留了旧路径),而这次它长在**修那个形状的提交里**。 ## 二、`checks`/`pass` 的单位是 `# pass`,不是 `# tests` node:test 打三行 —— `# tests N` / `# pass N` / `# fail N`,且 `# tests = # pass + # fail`。 而原代码取的是 `/^# pass (\d+)/`: ``` Σ 自报 pass = 399、Σ 自报 fail = 1 ⇒ 真实总条数 400 汇总行: checks=399 pass=398 ← 两格都少算 ``` - `checks` = Σ`# pass` ⇒ **少了失败的那几条**; - 接着 `pass = checks - fail` = 399 − 1 = 398,**在一个从未包含失败的和上又减了一次失败** (真实通过数是 399)。 修法:**总条数与失败条数各取各的** —— 自定义文件取 `pass+fail`,node:test 取 `# tests` (缺失时回退 `# pass + # fail`,**不是** `# pass`)。 修后:`checks=400 pass=399 fail=1`,与 pi 用我的输出算出的数**逐字一致**。 ★ 这一步的真正价值不是那两个数字,而是:**`pass + fail == checks` 从"定义式"变成了真不变量**。 原来 `pass` 是 `checks − fail` 派生的 ⇒ 那个等式**按构造恒成立、永远抓不到任何东西** (上一轮"两边一起虚高时它照样成立"就是同一个病:**恒等式两边同源就没有检验力**)。 **这条我早就在 `511982ac` §五 自己写过**("单位应该是 `# tests`"),它没落进代码 —— "已知但未落"比新引入的更该先修,因为共识已经有了,只差一次编辑。 ## 三、★ 我加的第一版自检是**空的**,自己测出来了 pi 建议给 `unreportedFiles` 补一条 `unreported ≤ files` 上界。我先照做了,然后测了一下: > 一次运行里一个文件只被处理一次,而 bug 的效果是**同一次处理里加两次** ⇒ > 只有 1 个这种文件时 `unreported=2`,而 `2 > 25` **为假** ⇒ **永不触发**。 > 要让它触发得需要 >25 个"绿且不自报"的文件,而套件只有 25 个文件。 > ⇒ **数学上成立、实际上永远抓不到任何东西的判据** —— 而我是**在加它的时候**又造了一个。 (顺带更正上面那条 `ran > files`:它同样只在"几乎所有文件都双计"时才响 —— 也就是**那个 bug 正好发生时它才响**。侥幸有效,但机理不是我以为的那个。) 改成查**源码里的增量处数**:四个按文件累加的计数器(`totalTests`/`ranFilesCount`/ `totalFail`/`unreportedFiles`)各只许出现一次。**形态判据**(读自己的源码),不是行为判据 —— 它能抓"搬动/新增时留了旧路径"(实际发生的那三次),抓不到"某条路径上被条件性地加两次"。 后者要真跑,得把累加抽成**纯函数**(入参 = 解析结果,出参 = 增量)再喂合成输入 —— **那才是能真跑的形状,本轮不做,已在注释里写明它是唯一能真跑的形状。** 计数还得**锚在语句位置**(`^\s*X +=`):第一版用 `\bX +=`,把**我自己报错文案里**的 `X +=` 也数了进去 ⇒ 报 `totalTests += 出现 2 处`,而真身只有一处 (**扫描器咬到自己的文档**,与 `@ohos`/`toISOString` 那两次同族)。 **变异验证**:`unreportedFiles` 两处 ⇒ 红;`ranFilesCount` 两处 ⇒ 红;还原 ⇒ 绿。 单位那条的观测面也验了:改回 `# pass` ⇒ `checks=399 pass=398`(少算 1 条 = 失败条数)。 ## 四、顺带修掉一个我自己埋的崩:一个读不到的文件会把整条判据吃掉 `checks` 单位一改对,`commit-hygiene` 立刻**红了**(`fail=2 red=5`)—— 看着像是我改了计数引出的,其实是一条**真 bug 被暴露出来**:那条 AGC 判据裸用 `prose(join(ROOT, f))` 遍历**所有 tracked 文件**,而 `server/internal/repo/suggest_paths_test.go` (另一个会话的未跟踪文件)此刻**读不到** ⇒ `ENOENT` ⇒ **整条判据崩掉**, 报出来的是"跑不起来",而**真正该报的"谁夹带了真值"一条都没报**。 ⇒ **一道判据自己变成它要抓的东西的噪声。** 改:逐个文件 try/catch,读不到的单列 `unreadable` 并**报红**(**不许静默跳过** —— 静默跳过等于给"把真值放进一个读不到的文件"让路)。修后 4/4 绿,总数行回到 `pass=399 fail=1 red=4`。 ## 五、修后状态 ``` RESULT files=25 ran=25 checks=400 pass=399 fail=1 red=4 broken=0 unreported=0 verdict=red ok 5 - 总数行自洽(ran=25 ≤ files=25,unreported=0 ≤ files,fail 是 checks 的子集) ``` 红仍 4 条、**都不是我的**(`narrow-layout` 88>64、`nav-merge` 9>8、`harmony-presets` 6>5、 `build-stamp`)。到期那 7 条仍未动。 --- client/electron/test/commit-hygiene.test.mjs | 25 +++- client/electron/test/run-all.mjs | 113 ++++++++++++++++--- 2 files changed, 123 insertions(+), 15 deletions(-) diff --git a/client/electron/test/commit-hygiene.test.mjs b/client/electron/test/commit-hygiene.test.mjs index 4d3ac0b..3a685d0 100644 --- a/client/electron/test/commit-hygiene.test.mjs +++ b/client/electron/test/commit-hygiene.test.mjs @@ -206,14 +206,35 @@ test('★ 版本库里不许跟踪 AGC 配置真身(gitignore 挡不住 `git a { what: 'AGC api_key 信封', re: /"api_key"\s*:\s*"\[!/ } ]; const offenders = []; + /* + * ★ **一个读不到的文件不许把整条判据吃掉**(我自己撞上的): + * 原来这里是裸 `prose(join(ROOT, f))`。只要**任何一个** tracked 文件此刻读不到 + * (并发写入者正在写、刚 `git add` 还没落盘、被 `rm` 了),`prose()` 就抛 `ENOENT`, + * 整条判据**崩掉** ⇒ 报出来的是"跑不起来",而**真正该报的"谁夹带了真值"一条都没报**。 + * 实测:`server/internal/repo/suggest_paths_test.go` 被并发会话删掉的那一瞬间, + * 这条判据以 `ENOENT` 红,而它本该报的是 AGC 形状。 + * ⇒ **一道判据自己变成它要抓的东西的噪声**。所以逐个文件 try/catch,读不到的单独记账, + * 并且**读不到就明说**(不许静默跳过 —— 静默跳过等于给"把真值放进一个读不到的文件"让路)。 + */ + const unreadable = []; for (const f of files) { - // example 是**故意**带这些键名的(结构留、值全打掉)——按内容判,不按文件名豁免 - const src = prose(join(ROOT, f)); + let src; + try { + src = prose(join(ROOT, f)); + } catch (e) { + unreadable.push(`${f}(${(e && e.code) || 'ERR'})`); + continue; + } const hit = SHAPE.filter(s => s.re.test(src)).map(s => s.what); // 只声明了键名但没有真值(值全是 `<…>`)的不算违规 const onlyPlaceholders = !/\[!00/.test(src) && !/"[0-9A-F]{32,}"/.test(src); if (hit.length > 0 && !onlyPlaceholders) offenders.push(`${f}(${hit.join('、')})`); } + assert.deepEqual(unreadable, [], + '这些 **tracked** 文件此刻读不到 ——\n' + + ' 这条判据要**逐个读 tracked 文件**才能判"谁夹带了 AGC 真值",读不到就判不了。\n' + + ' ★ 不静默跳过:静默跳过等于给"把真值放进一个读不到的文件"让路。\n' + + ` 常见成因:并发会话正在写/删(本仓是共享工作树)。重跑一次通常就好。\n ${unreadable.join('\n ')}`); assert.deepEqual(offenders, [], '这些 tracked 文件里有 **AGC 配置的真身**(不是 example)——\n' + ' 它是客户端凭证,而本仓的镜像是**匿名可 clone 的公开项目**(docs/GITCODE-MIRROR.md):\n' + diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 8e0964e..2d63839 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -189,7 +189,19 @@ const reds = []; * 它只累加**它自己实际跑过并解析到自报条数**的那些文件,不猜、不扫目录。 */ let totalTests = 0; -/** 逐文件自报的**失败**条数之和(`fail=` 那一格)。`pass + fail == checks` 是恒等式 */ +/** + * 逐文件自报的**失败**条数之和(`fail=` 那一格)。 + * + * ⚠️ 别再把这行写成"`pass + fail == checks` 是恒等式"就算完了 —— + * 汇总行里的 `pass` 是 `totalTests - totalFail` **派生**出来的,所以那个等式 + * **按构造恒成立、永远抓不到任何东西**(上一轮"两边一起虚高时它照样成立"就是同一个病: + * **恒等式两边同源就没有检验力**)。 + * + * 现在**两边各自独立测得**:`checks` 取文件的**总条数** + * (自定义文件 `pass+fail`、node:test 取 `# tests`),`fail` 取文件的失败条数。 + * 所以 `pass + fail == checks` 从"定义式"变成了**真不变量** —— + * 单位取错(例如用 `# pass` 当总条数)现在会**直接破坏它**,而不是被它掩盖。 + */ let totalFail = 0; /** * **实际跑了但没能自报条数**的文件数 —— 这个数必须显式打出来。 @@ -280,19 +292,41 @@ for (const [file, flags, expected] of SUITE) { * `checks` **静默少算**。实测:`checks=379` 而逐文件手算 387 —— 差的那些正是红文件里的。 * **"红"只说明判据说"不成立",不说明它没跑**;跑过的条数一样要算进来。 * - * 单位是自报的条数本身(`RESULT pass=N fail=M` 的 N+M,或 `# tests N`), - * 两种文件的"条"不是同一种条,所以汇总里那一格叫 `checks` 而不叫 `tests`。 + * ★ **单位必须是"总条数",不是"通过条数"**(pi 2026-09-15 指出,我 511982ac §五 + * 自己就写过这条、但**没落进代码**): + * + * node:test 会打三行 —— `# tests N` / `# pass N` / `# fail N`,而 `# tests = # pass + # fail`。 + * 原来取的是 `/^# pass (\d+)/` ⇒ `checks` = Σ`# pass` = **399**, + * 比真实总条数(400)**少了失败的那几条**;接着 `pass = totalTests - totalFail` + * = 399 − 1 = **398**,**在一个从未包含失败的和上又减了一次失败**(真实通过数是 399)。 + * 实测(唯一有失败的文件 `build-stamp`:`# tests 7 / # pass 6 / # fail 1`): + * Σ 自报 pass = 399、Σ 自报 fail = 1 ⇒ 真实总条数 **400** + * 汇总行却是 `checks=399 pass=398` —— 两格都少算。 + * + * ⇒ 所以:**总条数与失败条数各取各的**,`pass` 只作为**派生量**打印。 + * 这样 `pass + fail == checks` 才从**定义式**(`pass := checks - fail`,按构造恒成立、 + * 永远抓不到任何东西)变成**真不变量**(两边各自独立测得)。 + * 上一轮那条"虚高时恒等式照样成立"是同一个病 —— **恒等式两边同源就没有检验力**。 */ - const marker = /RESULT pass=(\d+) fail=(\d+)/.exec(out); - const nodeTest = /^# pass (\d+)/m.exec(out); + const marker = /RESULT pass=(\d+) fail=(\d+)/.exec(out); // 自定义 check():pass/fail 自报 + const nodeTests = /^# tests (\d+)/m.exec(out); // node:test:总条数 const nodeFail = /^# fail (\d+)/m.exec(out); - const ran = marker ? Number(marker[1]) : (nodeTest ? Number(nodeTest[1]) : null); + /* + * `# tests` 缺失时**回退**到 `# pass + # fail`(不是 `# pass`)—— + * 老版本 node 或输出被截断时,和才等于总条数;单取 `# pass` 正是上面那个 bug。 + * 两者都没有 ⇒ null("没自报条数",走 unreported 那条红)。 + */ + const nodePass = /^# pass (\d+)/m.exec(out); + const nodeTotal = nodeTests ? Number(nodeTests[1]) + : (nodePass ? Number(nodePass[1]) + (nodeFail ? Number(nodeFail[1]) : 0) : null); + const ran = marker ? Number(marker[1]) + Number(marker[2]) : nodeTotal; + const failed = marker ? Number(marker[2]) : (nodeFail ? Number(nodeFail[1]) : 0); if (ran !== null) { totalTests += ran; ranFilesCount += 1; - totalFail += marker ? Number(marker[2]) : (nodeFail ? Number(nodeFail[1]) : 0); + totalFail += failed; } else { - unreportedFiles += 1; + unreportedFiles += 1; // ★ 唯一的增量处(另一份在绿色 else 里,已删,见下) } // 不 break:后面每条都要跑出来,否则"红了几条"这个信息本身是假的 if (crashed || empty) { @@ -328,9 +362,16 @@ for (const [file, flags, expected] of SUITE) { * * ⇒ 教训写在这儿:**搬动一段代码时,删旧的那一步要和加新的那一步一起提交**; * 只加不删的话,两处都跑得通、都报得出数,而**没有任何东西会红**。 + * + * ★ 而**我自己就是这条教训的下一个实例**(pi 2026-09-15 复查抓到): + * 我在上一次修这里时,删掉了那份 `totalTests`/`ranFilesCount`/`totalFail`, + * **却把紧跟的 `unreportedFiles += 1` 留在了原地** —— 于是它**仍然计两次** + * (`:317` 的 `else` 一次、这里又一次)。我在信里写"顺带修掉了",**代码里没有**。 + * "说修了"与"修了"是两件事,而**只有后者可复核** —— 这正是本仓反复立的规矩。 + * ⇒ 这一行现在删掉了,并且由自检 5 的 `unreportedFiles ≤ files` 钉着 + * (它今天不可见:`unreported=0`,没有文件触发 —— **看不见正是它需要结构性上界的理由**)。 */ if (ran === null) { - unreportedFiles += 1; /* * 报错**自带修法**(pi 2026-09-14):这条契约的受众不只是读过规范的人 —— * 并发写 WebUI 的 agent 新加判据时不会打开 CRITERIA.md,看到红的第一反应 @@ -625,6 +666,14 @@ try { * 不需要知道任何自报条数就能判。`checks` 没有这种上限(条数是文件自己报的), * 所以别在这里给 `checks` 编一个 —— 那会变成"用猜数字的判据"。 * + * ★ **这个上界对"每一个按文件累加的计数器"都成立**(pi 2026-09-15 指出我只判了两个): + * 它们一共三个 —— `ranFilesCount` / `totalFail` / `unreportedFiles`。 + * 我漏了 `unreportedFiles`,而它当时**真的仍然计两次**(我上一次"修好了"的那个提交里, + * 删了 `totalTests` 那三行却把紧跟的 `unreportedFiles += 1` 留在原地)。 + * ⇒ 教训:**"每个文件只该计一次"是一条对整族计数器都成立的性质,不是对某一个**; + * 只给其中两个判上界,第三个就永远没人管。 + * (`totalFail <= checks` 只用 "fail 是 checks 的子集",比 `<= files` 更紧且同样结构性。) + * * ⚠️ 这条自检管的是**总数的自洽性**,不是"条数对不对":`checks` 的正确值只有 * 逐文件核得出(这正是这个 bug 难发现的原因)。它拦的是"又一次搬动只加不删"。 */ @@ -638,12 +687,47 @@ try { if (totalTests < totalFail) { probs.push(`checks=${totalTests} < fail=${totalFail} —— fail 是 checks 的子集,这不可能成立`); } + /* + * ★ **"每个文件计一次"必须查"增量语句有几处",不能查数值上界** + * (pi 2026-09-15 指出我漏了 `unreportedFiles` 之后,**我自己测出来的更正**): + * + * 我原打算给 `unreportedFiles` 补一条 `unreported ≤ files` 的上界 —— **它是空的**: + * 一次运行里一个文件只被处理一次,而 bug 的效果是**同一次处理里加两次** ⇒ + * 只有 1 个这种文件时 `unreported=2`,而 `2 > 25` 为假 ⇒ **永不触发**。 + * 要让它触发得需要 >25 个"绿且不自报"的文件,而套件只有 25 个文件。 + * ⇒ **数学上成立、实际上永远抓不到任何东西的判据** —— + * 这正是我们这一路在消的形状,而我是**在加它的时候**又造了一个。 + * (顺带更正上面那条 `ran > files`:它同样只在"几乎所有文件都双计"时才响, + * 也就是**那个 bug 正好发生时它才响** —— 侥幸有效,但机理不是我以为的那个。) + * + * 所以改成查**源码里的增量处数**:四个计数器各只许出现一次。 + * 诚实说清性质:这是**形态判据**(读自己的源码),不是行为判据 —— + * 它能抓"搬动/新增时留了旧路径"(实际发生的那三次), + * 抓不到"在某条路径上被条件性地加两次"。 + * 后者要真跑,得把累加抽成一个纯函数(入参 = 解析结果,出参 = 增量),再拿合成输入喂它。 + * **那才是能真跑的形状,本轮不做,但记在这里**。 + * + * ⚠️ 计数必须**锚在语句位置**(行首缩进后即 `X +=`),否则**我这条报错文案自己**里 + * 出现的 `X +=` 会被数进去 —— 第一次跑就是 `totalTests += 出现 2 处`, + * 而真身只有一处(**扫描器咬到自己的文档**,与 `@ohos`/`toISOString` 那两次同族)。 + * `stripComments` 只去注释、**不去字符串**,所以这里不能只靠它。 + */ + const ownSrc = stripComments(prose(fileURLToPath(import.meta.url))); + for (const counter of ['totalTests', 'ranFilesCount', 'totalFail', 'unreportedFiles']) { + const n = (ownSrc.match(new RegExp(`^\\s*${counter}\\s*\\+=`, 'gm')) || []).length; + if (n !== 1) { + probs.push(`\`${counter} +=\`(语句位置)出现 **${n}** 处(只许 1 处)—— ` + + `"每个文件只该计一次"是对**整族**计数器都成立的性质,不是一个一个判的;\n` + + ` 这个形状已经发生过三次(搬动/新增时留了旧路径),而**两处都跑得通、都报得出数**。`); + } + } if (probs.length) { console.error('✗ 套件自检 5(总数行的自洽性):'); for (const x of probs) console.error(` - ${x}`); selfCheckFailed = true; } else { - console.log(`ok 5 - 总数行自洽(ran=${ranFilesCount} ≤ files=${SUITE.length},fail 是 checks 的子集)`); + console.log(`ok 5 - 总数行自洽(ran=${ranFilesCount} ≤ files=${SUITE.length},` + + `unreported=${unreportedFiles} ≤ files,fail 是 checks 的子集)`); } } catch (e) { console.error(`✗ 套件自检 5 跑不了:${e.message || e}`); @@ -780,11 +864,14 @@ console.log(`\n========== 判据汇总 ==========`); * * ⚠️ 单位要写清:`checks` 是**逐文件自报条数之和** —— 也就是套件在每跑完一个文件时 * 真正解析过、并据此判"有没有少跑"的那个数。它**混两种单位**: - * `check()` 文件自报 `RESULT pass=N`(N 条判据),`node:test` 文件自报 `# pass N` - * (N 个 test 用例)。两者都是"条",但**不是同一种条**,所以这里叫 `checks` 而不叫 + * `check()` 文件自报 `RESULT pass=N fail=M`(取 N+M 为总条数), + * `node:test` 文件自报 `# tests N`(总条数)。 + * 两者都是"条",但**不是同一种条**,所以这里叫 `checks` 而不叫 * `tests`,免得下一个人把它跟 `node --test test/*.test.mjs` 的 glob 口径直接比 —— * 那个口径只跑标准 `test/*.test.mjs`,**不跑套件里的构建相位**,也不含本次相位跳过。 - * `pass + fail == checks` 恒成立(fail 是那些文件里的红条数)。 + * `pass + fail == checks` 现在是**真不变量**(两边各自独立测得,见 `totalFail` 的注释)—— + * 它此前是定义式(`pass` 由 `checks - fail` 派生),所以**形同虚设**: + * 单位取成 `# pass` 时 `checks` 与 `pass` 一起少算,那个等式照样成立。 */ /* * ★ 这一行的**三个"坏"计数单位不同**,别混着读(pi 2026-09-15 指出缺一格):