/** * 判据总入口 —— **全部跑完再算退出码**。 * * 为什么不再用 `&&` 串起来: * * 原先 `npm test` 是 `a && b && c …`。这种行为有个不起眼但很贵的后果 —— * **前面红一条,后面全部不跑**。于是"只红了一条"看起来像"只有一个问题", * 实际上后面那些判据连跑都没跑(这次就真发生了:`background` 红着, * `packaging` 从来没跑到过,而它正是能发现"界面改了没重打包"的那条)。 * 换句话说:`&&` 链下的"全绿"是可信的,**"红"是不可信的**。 * * 现在:每条判据都跑,红的收集起来,最后一起报、一起退出。 * * 另外两条防"判据自己不会跑"的自检(与 process.exit 之后写判据是同一族问题): * 1. 清单里的文件必须存在(名字写错 = 静默跳过一条判据); * 2. `test/` 下的每个 `*.test.mjs` 都必须在清单里 * —— 这次 `cross-client-theme.test.mjs` 就是"写好了但没接进套件", * 在它进套件之前一直是隐身状态。加了这条,**新增判据忘了接线会直接红**。 * 3. 判据规范 `test/CRITERIA.md` 要在、且要点到那几条规则 * —— 写判据的规矩本身也会被"忘了带"(形状记在某个人的脑子里等于没有)。 * * 写判据之前先读 `test/CRITERIA.md`(判结构与行为,不判字面与邻接)。 */ import { prose, stripComments } from './lib/read.mjs'; import { spawnSync } from 'node:child_process'; import { existsSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..'); /** * 判据清单:[文件, 额外 node 参数]。 * * `--test` 给用 node:test 写的判据;鸿蒙那条要 `--experimental-strip-types` * 才能直接执行 `client/harmony/.../MailGrouping.ts`(判据跑的是客户端真正引用的那份逻辑)。 */ /* * 自检 3:判据规范在不在、有没有写到那几条关键规则。 * * 为什么把"文档"也判:`CRITERIA.md` 里的每条都是踩出来的(窗口式判据、邻接式判据、 * 生成的清单被侵蚀、剥注释读不到理由……)。规则只在某个人的脑子里时,下一个人会重踩一遍; * 文件被删/被搬走却没人发现,等于规则也没了。这里只断"还在 + 关键条目还在", * 不断它的措辞 —— 那是笔记,不是接口。 */ const CRITERIA_DOC = join(HERE, 'CRITERIA.md'); if (!existsSync(CRITERIA_DOC)) { console.error('✗ 判据规范 test/CRITERIA.md 不见了(写判据的规矩不能只活在脑子里)'); process.exit(1); } const criteriaDoc = prose(CRITERIA_DOC); for (const must of ['配对/解析', 'allow-list', '变异验证', '剥掉注释', '按行', '自报条数', '只支撑你看到的那一层', '已经在某个提交里', '自带修法', '按 id 联接', '不要退化成对源码形状的匹配']) { if (!criteriaDoc.includes(must)) { console.error(`✗ 判据规范里少了「${must}」这条 —— 规则被删掉了还是搬走了?`); process.exit(1); } } const SUITE = [ ['test/markdown-xss.test.mjs', [], 9], ['test/narrow-layout.test.mjs', [], 64], ['test/nav-merge.test.mjs', [], 8], ['test/animation-audit.test.mjs', [], 4], // 动画全量盘点:死动画/过宽作用域/弹层接线/reduced-motion ['test/theme.test.mjs', [], 30], ['test/background.test.mjs', [], 43], ['test/cross-client-theme.test.mjs', [], 15], // 预设的**行为**判据:每一档都真的画得出来(能真跑,不需要设备 ⇒ 不进 static 欠账)。 // 与 appearance-defaults 那条「清单 id/顺序相等」配对:值判据管清单,行为判据管渲染器。 ['test/harmony-presets.test.mjs', ['--experimental-strip-types', '--no-warnings'], 5], ['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 28], ['test/harmony-system-api.test.mjs', [], 5], // P4 外观同步:跑 model/Appearance.ts(纯逻辑),所以也要 strip-types ['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 25], // P5 悬浮玻璃导航:点击配对 / index 决定挂载 / 命中区 ≥44vp / 悬浮与让位 ['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 11], // 外观契约:默认值去 Go 源码里读(服务端 DefaultAppearance 是权威)+ 缓存键按账号 ['test/appearance-defaults.test.mjs', [], 4], ['test/build-stamp.test.mjs', [], 7], ['test/packaging.test.mjs', [], 5], ['test/align-refs.test.mjs', [], 3], ['test/harmony-deviceprobe.test.mjs', ['--experimental-strip-types', '--no-warnings'], 8], ['test/harmony-push.test.mjs', ['--experimental-strip-types', '--no-warnings'], 13], // 服务器地址(apiBase):补 /api/v1 / 去尾斜杠不吃协议 // / 校验自带修法 / // 明文只对公网告警 / 404 说清“少了 /api/v1” / 网络错误码分类 / 归一化只有一份实现。 // 值判据跑真逻辑(model/ApiBase.ts);`.ets` 那几条是**静态**接线判据(无设备)。 ['test/harmony-apibase.test.mjs', ['--experimental-strip-types', '--no-warnings'], 13], ['test/harmony-calendar.test.mjs', ['--experimental-strip-types', '--no-warnings'], 23], ['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', [], 6], // 用户管理页(P4c 同批):动作↔服务端调用同名 / 门禁只认严格 admin / // 启停只发 status / 「受限」徽标口径 / 页面零写死色值 / 接线(纯逻辑真被调用) ['test/harmony-admin.test.mjs', ['--experimental-strip-types', '--no-warnings'], 22], // P4c 图片上传:阈值与两档策略 / 失败必带原因 / 退档判定只有一处 / // release 都 await / 解码按目标尺寸 / multipart 字段名 / 上传后重新同步 ['test/harmony-imageprep.test.mjs', ['--experimental-strip-types', '--no-warnings'], 29], // ArkTS **编译期**硬规则(纯文本可判、不需要设备)。这一条是构建撞出来的: // 我把常量表插在了既有 import 之前 ⇒ arkts-no-misplaced-imports,而当时没有任何判据会跑它。 ['test/harmony-arkts.test.mjs', [], 3] ]; // 自检 1:清单里的文件必须真的存在(写错名字 = 那条判据永远不跑) const ghosts = SUITE.map(([f]) => f).filter((f) => !existsSync(join(ROOT, f))); // 自检 2:test/ 下每个 *.test.mjs 都要在清单里(防"写好了没接线") const onDisk = readdirSync(join(ROOT, 'test')) .filter((f) => f.endsWith('.test.mjs')) .map((f) => `test/${f}`); const unwired = onDisk.filter((f) => !SUITE.some(([s]) => s === f)); if (ghosts.length || unwired.length) { if (ghosts.length) console.error(`清单里的判据文件不存在:${ghosts.join('、')}`); if (unwired.length) { console.error(`这些判据文件没接进套件(写了却不会跑):${unwired.join('、')}`); } process.exit(1); } /* * 自检 3:判据不得写在 `process.exit()` **之后**(pi 提议,2026-09-14)。 * * 自检 1/2 管的是"文件没接线",管不到"检查写在了退出之后" —— 而那正是实际发生过的 * 第 4 例:4 条玻璃判据被并发写入落到了文件末尾、`process.exit()` 后面, * 于是**一条都不执行、也不计入通过/失败**,输出看起来完全正常。 * 这种事的成因是结构性的(并发写入总是往文件末尾追加),所以它一定会再发生, * 而它下一次仍然不报错 —— 静态扫一遍最省事。 */ const buried = []; for (const [file, flags] of SUITE) { if (flags.includes('--test')) { continue; // node:test 那几条没有 process.exit,结构上不会踩这个 } const src = prose(join(ROOT, file)); const exitAt = src.lastIndexOf('process.exit('); if (exitAt >= 0 && /(^|\n)\s*check\(/.test(src.slice(exitAt))) { buried.push(file); } } if (buried.length) { console.error(`判据写在 process.exit() 之后,永远不会跑(挪到汇总之前):${buried.join('、')}`); process.exit(1); } /* * 自检 4(pi 2026-09-14 提的家族,第 6 例):**"判据自己不会跑"**。 * 第 6 例的宿主是 runner 自己:清单里的 flag 与判据写法如果配错,症状是"看起来全绿"。 * * ⚠️ 落地前先实测了两条真实样本,结论与 pi 的猜测**不同**,记在这里免得后人重猜: * - `node --test <自定义 check() 的判据>`:**退出码照样传出来**(文件 exit 1 → 命令行 exit 1), * 并没有被 runner 吞掉; * - 但 `node --test <什么都不做的文件>` 会报 `# tests 1 / # pass 1` —— * **计数不是"检查跑过"的证据**。所以"解析 pass 计数、0 就判红"这条路既 * 抓不到空判据(它报 1),又会在 `narrow-layout`(汇总行"全部通过"里没有数字)上误报。 * * 换成**结构证据**:每条判据文件里必须存在"能红"的路径 —— * node:test 的 `test(`、自定义 `check(`、或显式 `process.exit(1)`。 * 一个都没有 = 它永远不会红,与"全通过"长得一模一样。 * 再加一条"跑完必须有输出"(12 条判据现在都有输出),静默成功同样可疑。 */ function shapeOf(file) { const src = prose(join(ROOT, file)); const usesNodeTest = /from 'node:test'/.test(src); const canFail = usesNodeTest || /(^|[^.\w])check\(/.test(src) || /process\.exit\(\s*1\s*\)/.test(src); return { usesNodeTest, canFail }; } const shapeless = []; for (const [file, flags] of SUITE) { if (flags.includes('--test')) { console.error(`清单里不要手写 --test(它由文件内容推导):${file}`); process.exit(1); } if (!existsSync(join(ROOT, file))) continue; // 自检 1 已经报过了 if (!shapeOf(file).canFail) shapeless.push(file); } if (shapeless.length) { console.error('这些判据文件里找不到任何"能红"的路径(test( / check( / process.exit(1)):' + `${shapeless.join('、')} —— 它们永远不会红,与"全通过"看起来一样`); process.exit(1); } const reds = []; /* * 判据**条数**的累计(pi 2026-09-15)。 * * 为什么要有它:套件此前**不打总数** —— 于是每次要"一共多少条、过了多少条", * 都得在套件外面用 `node --test test/*.test.mjs` 那样的 **glob 调用法**绕出去。 * 而 glob 那套正是让"写了判据却没接线"逃掉的那个调用法(那次 `SUITE` 里少两个文件, * 绕出去的 glob 反而把它们跑绿了)。⇒ 权威来源必须由**套件自己**给出: * 它只累加**它自己实际跑过并解析到自报条数**的那些文件,不猜、不扫目录。 */ const records = []; /** * 各计数器**不在这里累加** —— 它们由下面的 `records` **投影**出来(见 `records` 的说明)。 * * 旧形状是这四个 `let` 各自 `+=` 一处,靠"源码里每个计数器只许一处写"的**文本扫描**保证。 * 那个形状长出了一整族扫描器毛病(注释/字符串/等价写法/锚/正则引号), * 而**改成投影后这一族连扫描对象都不存在** ⇒ 四个计数器与那条扫描自检一起删掉了。 * 读它们的地方一个字都没改(`RESULT` 行、自检 5、汇总),只是它们现在是**派生值**。 */ // 下面这四行是**投影**(`totalTests`/`totalFail`/`ranFilesCount`/`unreportedFiles` 的旧名保留, // 让读的地方不用改;它们的值全部来自 `records`,没有任何一步是"就地累加")。 /* eslint-disable no-unused-vars */ let totalTests; let totalFail; let unreportedFiles; let ranFilesCount; /* eslint-enable no-unused-vars */ /* * ─── 相位(pi 2026-09-14 裁定)─── * * 同一份套件会被**两个不同相位**调用: * · 构建/发布相位(`npm test`、`--release`):问"我要产出的东西是不是从**当前源码**新鲜构建的?" * → `packaging`(包内 dist == 当前 dist)、`build-stamp`(产物 == HEAD)在这一相位才有意义; * · 安装相位(`deploy/install.sh`,见 `AGENTMAIL_CRITERIA_PHASE=install`): * 问"**别人已经产出的这个东西**能不能装到这台机器上?" —— 这里"dist 与源码树的关系" * 根本不是该问的问题:部署路径不重新打包(也**不应该**重新打包,否则"review 过的产物" * 和"装上去的产物"不是同一个东西,而且会重新构建的干跑不是干跑)。 * * 所以规则(可机检):**部署门禁只允许读"待安装的产物 + 目标机状态",不许依赖 * "源码树是最新构建的"这个前提。** 一条门如果在某个相位恒红或恒绿,那不是门的问题, * 是**相位挂错了** —— 今天撞到三条:`check-shared-libs`(从 153985e 起恒红)、 * `packaging`(前端一改就卡死)、`HOME: unbound variable`(在门禁跑完之后才炸)。 * * 下面登记每条判据读的是哪一侧;`install` 相位里出现 `SOURCE` 侧判据 → 直接红。 */ const SIDE = { ARTIFACT: 'artifact', // 只读产物 / 目标机状态(装得上装不上) SOURCE: 'source' // 依赖"源码树是最新构建的"(构建相位才成立) }; /** 判据文件 → 它读的那一侧。没登记的按 ARTIFACT(读源码的必须显式登记,否则会被这条例外掉) */ const SIDES = { 'test/build-stamp.test.mjs': SIDE.SOURCE, // 产物 gitRev/srcHash == 当前源码树 'test/packaging.test.mjs': SIDE.SOURCE // 包内 dist == 当前 dist }; const PHASE = process.env.AGENTMAIL_CRITERIA_PHASE === 'install' ? 'install' : 'build'; function sideOf(file) { return SIDES[file] || SIDE.ARTIFACT; } const brokens = []; /* * ★★ 本相位**实际会跑**的文件 —— 期望值必须由**同一个判据**算出来。 * * pi 2026-09-15 抓到的假红:我上一版把"记录数"跟 `SUITE.length` 比, * 而 install 相位有 2 条构建相位判据**进不了循环**(不 push 记录)⇒ `records=23 ≠ 25` ⇒ 报红, * 报文还给出**恰好错误**的成因("漏了或多跑了")—— 什么都没漏、也没多跑。 * 而这条命令正是 `install.sh --check` 的前端门禁(`AGENTMAIL_CRITERIA_PHASE=install npm test`) * ⇒ 一个**假红**会打出一行 `[FAIL]`,教读者"那行 `[FAIL]` 可以忽略"。 * * 所以:期望值 = 在**本相位**下不被跳过的文件数,而不是套件总数。 * 并且它和下面的 skip 判定用**同一个谓词**(同一个 `sideOf`),免得两处各写一份条件而漂移 —— * "同一个事实有两份实现"正是这一路反复出现的形状。 */ const phaseWillRun = SUITE.map(([f]) => f) .filter(f => !(PHASE === 'install' && sideOf(f) === SIDE.SOURCE)); const skippedByPhase = []; for (const [file, flags, expected] of SUITE) { if (PHASE === 'install' && sideOf(file) === SIDE.SOURCE) { skippedByPhase.push(file); continue; } console.log(`\n========== ${file} ==========`); const shape = shapeOf(file); const all = shape.usesNodeTest ? [...flags, '--test'] : flags; // 收集输出再自己打回去:观感不变(stdio:'inherit' 的等价物),但能顺手做"跑了吗"的检查 const r = spawnSync(process.execPath, [...all, join(ROOT, file)], { encoding: 'utf8' }); const out = (r.stdout || '') + (r.stderr || ''); process.stdout.write(r.stdout || ''); process.stderr.write(r.stderr || ''); /* * `broken` 与 `red` 必须分开(pi 2026-09-14 §3)。 * * 我这轮两次把"跑不起来"当成"判据红了":一次变异注入少了 import(报 build failed)、 * 一次判据里写了没绑定的标识符(ReferenceError 抛在判据自己身上)。 * 两者的后果都是同一种骗人方式:**看起来像判据失败,其实是判据没跑**。 * 所以判据是:退出码非零 **且输出里没有一句"断言失败"** → 那是 broken(崩了),不是 red。 * 变体验证里出现 broken = **这次变异无效,重做**,不许记成"红过了"。 */ /* * **别解析人类可读的文本**(pi 2026-09-14 §5):文本格式是运行器的实现细节。 * 第一版我用"输出里有没有 `not ok`"判崩,当场误判 —— node:test 把**导入期**的 * `ReferenceError` 也报成 `not ok 1 - …`,于是"崩了"和"断言失败"在文本上同形。 * * 现在按 TAP 的**名字**判,而不是按错误正文猜:文件级失败的测试名就是**文件路径**, * 而真正的断言失败名字是判据名。所以: * 所有 `not ok` 的名字都像路径(含 .mjs / 以 / 开头)→ **broken(判据没跑起来)**; * 只要有一个名字像判据名 → **red(判据说不成立)**。 * 再叠一层语言级崩痕只是兜底(没有 `not ok` 却退出非零的情形)。 * * 我不再往里加"更多错误关键字"——那是往文本解析里加补丁,方向是错的。 */ const notOkNames = [...out.matchAll(/^not ok \d+ - (.*)$/gm)].map(m => m[1].trim()); const looksLikePath = n => /(^\/|\.mjs\b|\.ts\b|\.js\b)/.test(n); const assertionFailed = notOkNames.some(n => !looksLikePath(n)); const onlyFileLevel = notOkNames.length > 0 && !assertionFailed; const CRASH_SIGNS = /(SyntaxError|ReferenceError|TypeError|Cannot find module|ERR_MODULE_NOT_FOUND|is not defined|is not a function|CompileError|build failed|Unexpected identifier|missing ',' in argument list)/; const crashed = r.status !== 0 && (onlyFileLevel || (notOkNames.length === 0 && CRASH_SIGNS.test(out))); const empty = out.trim().length === 0; /* * ── 条数累计:**必须在红/绿分支之外** ── * * 原来它写在下面 `else { … }`(只在**绿**时进入)里,于是**红文件一条都不计入**, * `checks` **静默少算**。实测:`checks=379` 而逐文件手算 387 —— 差的那些正是红文件里的。 * **"红"只说明判据说"不成立",不说明它没跑**;跑过的条数一样要算进来。 * * ★ **单位必须是"总条数",不是"通过条数"**(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); // 自定义 check():pass/fail 自报 const nodeTests = /^# tests (\d+)/m.exec(out); // node:test:总条数 const nodeFail = /^# fail (\d+)/m.exec(out); /* * `# 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 fileChecks = marker ? Number(marker[1]) + Number(marker[2]) : nodeTotal; const fileFail = marker ? Number(marker[2]) : (nodeFail ? Number(nodeFail[1]) : 0); /* * ★★ 唯一的信息来源:**这个文件只 push 一条记录**(pi 2026-09-15 的建议,我采纳)。 * * 旧写法是四个模块级 `let` 各自 `+=`,于是"每个计数器只许一处写"只能**靠扫描源码文本**去盯 * —— 而那条扫描器自己就长出了一整族毛病:注释、字符串、`= X + n`、`X++`、锚钉在行首 * vs 语句起点、正则字面量里的引号(**最后这条还是假绿方向:会吞掉真写**)。 * 每一轮修好其中一个实例,族还在。 * * ⇒ 换成投影后:**"把一个文件计两次"没有对应的语句可以写** —— * 不是"判据盯着别写",而是**写不出来**(要计两次得先把同一条记录 push 两次, * 而那件事有**行为**判据盯着,见汇总处的 `records.length === SUITE.length`)。 * 那一整族(注释/字符串/拼法/锚/正则引号)**连扫描对象都不存在了**。 * * 注意这里刻意**不做任何累加** —— `ran === null` 的含义("跑了但没自报条数") * 被原样记进记录,怎么解读交给下面那个投影,不在这里就地分类。 */ records.push({ file, checks: fileChecks, // null = 跑了但没能自报条数 fail: fileChecks === null ? null : fileFail, exitCode: r.status, }); // 不 break:后面每条都要跑出来,否则"红了几条"这个信息本身是假的 if (crashed || empty) { const firstErr = (out.match(/^.*(Error|error:).*$/m) || [''])[0].trim().slice(0, 160); brokens.push(`${file}(${empty ? '跑完没有任何输出' : `退出码 ${r.status},但没有一句断言失败`})` + (firstErr ? `\n ↳ ${firstErr}` : '') + '\n ↳ 这是 **broken(跑不起来)**,不是 red:它证明不了任何判据成立或不成立。' + '\n 常见成因:语法/标识符错(`X is not defined`)、import 写错、编译不过。' + '\n 用于变体验证时:broken **不算这次变异有效**,要重做。'); } else if (r.status !== 0) reds.push(`${file}(退出码 ${r.status})`); else { /* * ★ 这里**只保留"没自报条数"的那条红**;**任何累加都不在这里做** —— * 投影之后累加根本不作为语句存在,上面那条 push 出来的记录是唯一的信息来源。 * * 这段曾经是累加块(`totalTests += ran` / `ranFilesCount += 1` / `totalFail += …`)**唯一的** * 位置,而它在 `else` 里 ⇒ **红文件一条都不计入**,`checks` 静默少算 * (实测:`checks=379` 而逐文件手算 387,差的正是红文件里的)。 * 后来把累加搬到红绿**分支之外**, * **但没删掉这一份** —— 于是变成: * * `totalTests`/`ranFilesCount`/`totalFail` 对**每个 exit 0 的文件计两遍**, * 只有退出码非 0 的文件(当时是 `build-stamp`)计一遍。 * 实测吻合:25 个 section、24 个 exit 0、1 个非 0 * ⇒ `ran = 24×2 + 1 = 49`(汇总行正是 `ran=49`) * ⇒ `checks ≈ 逐文件单次合计 × 2`(`checks=792`) * * ★ 它是**自洽**的,所以读数检查不出来:`pass + fail == checks` * (791+1=792)**两边一起虚高,恒等式照样成立** —— * "自洽的读数不等于正确的读数"。而 `RESULT files=…` 这行本是为 * "总数要有权威来源"才加的,结果**它自己就是错的**: * 新机制进来时把旧路径留在原地。这和我们这一路在消的形状同族, * 只是这次长在总数行上(pi 2026-09-15 找到的)。 * * ⇒ 教训写在这儿:**搬动一段代码时,删旧的那一步要和加新的那一步一起提交**; * 只加不删的话,两处都跑得通、都报得出数,而**没有任何东西会红**。 * * ★ 而**我自己就是这条教训的下一个实例**(pi 2026-09-15 复查抓到): * 我在上一次修这里时,删掉了那份 `totalTests`/`ranFilesCount`/`totalFail`, * **却把紧跟的 `unreportedFiles += 1` 留在了原地** —— 于是它**仍然计两次** * (`:317` 的 `else` 一次、这里又一次)。我在信里写"顺带修掉了",**代码里没有**。 * "说修了"与"修了"是两件事,而**只有后者可复核** —— 这正是本仓反复立的规矩。 * ⇒ 这一行现在删掉了,并且由自检 5 的 `unreportedFiles ≤ files` 钉着 * (它今天不可见:`unreported=0`,没有文件触发 —— **看不见正是它需要结构性上界的理由**)。 */ if (fileChecks === null) { /* * 报错**自带修法**(pi 2026-09-14):这条契约的受众不只是读过规范的人 —— * 并发写 WebUI 的 agent 新加判据时不会打开 CRITERIA.md,看到红的第一反应 * 很可能是"套件坏了"(删自检、往清单里塞豁免)。**red 是 ta 一定会看到的东西, * 文档不一定会被打开** —— 所以把修法直接写进这条错误里,并给出可抄的样板。 */ reds.push(`${file} ↳ 没找到自报条数。修法(二选一): 1) 用共享 helper(新判据推荐): import { check, finish } from './lib/checks.mjs'; check('判据名', 条件, '失败时给人看的细节'); finish('标签'); // 它负责打 RESULT pass=N fail=M 样板:test/markdown-xss.test.mjs、test/narrow-layout.test.mjs 2) 自己打一行(老写法,计数必须写在 check() 内部,否则"实现被换空"看不见): console.log(\`RESULT pass=\${pass} fail=\${fail}\`); 样板:test/theme.test.mjs、test/background.test.mjs (用 node:test 写的判据不用管:runner 认 \`# pass N\`。)`); } else if (expected > 0 && fileChecks !== expected) { // **相等**,不是"不少于"(pi 邮件 `9fed4386`):只判下界时,**多加的条数不受保护** —— // 下一个人删掉那条"多出来的"判据不会有任何东西变红,于是"被删会红"这个性质只覆盖前缀。 // 两个方向的成因与处置都不同,所以分开写。 const why = fileChecks < expected ? `自报 ${fileChecks} 条 < 清单里登记的 ${expected} 条。常见成因:判据被删/被跳过(写在 process.exit() 之后、 条件里提前 return)、check() 的实现被改坏(合并冲突)、marker 打在了汇总之前但计数没接上。 确认确实该减少条数时,把清单里那个数字一起改掉(那是一次显式、可复核的编辑)。` : `自报 ${fileChecks} 条 > 清单里登记的 ${expected} 条 —— 新加的那几条**不在"被删会红"的保护内**: 只判下界时,删掉多出来的部分不会有任何东西变红。请把清单里这个数字改成 ${fileChecks}(一次显式编辑), 新判据才真正被登记保护。`; reds.push(`${file}\n ↳ ${why}`); } } } /* * ─── 投影:把 `records` 算成那四个计数器(**这里是它们唯一的"来处"**)─── * * ★ 为什么是这个形状(pi 2026-09-15 建议,我采纳并实测): * "每个文件只该计一次"原文是靠**扫源码文本**保证的,而那条扫描器自己长出了一整族毛病 * (注释 → 字符串 → `= X + n`/`X++` → 锚钉行首 vs 语句起点 → 正则字面量里的引号; * 最后这条还是**假绿方向**:会吞掉真写、把"其实有两处写"数成 1)。 * 换成投影后,"计两次"不再有对应的语句 —— 那一族**连扫描对象都不存在**。 * * ★ 但我要**修正 pi 的一句措辞**(实测过才敢说):他写"计两次**写不出来**"。 * `records.push` 本身**仍然可以被写两次**(复制粘贴就会),所以"写不出来"过强。 * 准确的说法:**重复不再表现为"某个计数器被加两次",而表现为"同一条记录出现两次"** ⇒ * 它可以被**行为**判据抓住(见下面的 `records.length === SUITE.length`), * 而不是只能靠"文本里 `+=` 有几处"去猜。 * ★ **这是"文本判据 → 行为判据",不是"可能错 → 不可能错"** —— * 前者可测,后者只是话好听。我不想把改进说成保证。 */ const reportedRecords = records.filter(r => r.checks !== null); totalTests = reportedRecords.reduce((n, r) => n + r.checks, 0); totalFail = reportedRecords.reduce((n, r) => n + r.fail, 0); ranFilesCount = reportedRecords.length; unreportedFiles = records.length - reportedRecords.length; /* * ─── 静态判据的**欠账**与到期机制(pi 2026-09-14 提议) ─── * * 有些判据只能验**形态**(读 `.ets` 源码),因为它们要验的东西在本机跑不起来: * 鸿蒙侧编译要 hvigorw、运行要设备/模拟器。这类判据登记在下面,并各自写清 * **什么前提一旦成立它就过期**。 * * 为什么不能只写一句"暂时":**"暂时"不是一种状态,是一个待办** —— 规范里写下的 * "暂时"没有任何机制会回来读它,于是永远留在原地。这里把它变成可机检的形状: * 1. `UNBLOCK` 必须是**可检测的前提**(这里就是"有没有可用设备"),不是陈述; * 2. 汇总里打 `RESULT static=N` —— 这是**欠账余额**,涨了要看得见; * 3. **前提一旦为真,欠账当场变红**:不等人想起来,设备可用的那天这些判据必须 * 改成行为判据(或明确降级并写理由)。 * * 这条是"自报 0 条 < 登记条数"的**时间版本**:那条管"判据还在不在",这条管 * "它该升级了没有"。 */ /** * 探针:判定静态判据的"到期前提"是否成立。 * * # 三值,不是两值(pi 2026-09-14 §2) * * 原先只有"成立/不成立"两种结果,于是**"探针跑不了"和"设备不可用"被归成同一格**: * 设备那天真可用了,闸门也永远不会开 —— 机制在,闸门锈死,而且看起来完全健康 * (这是"自报 0 条 < 登记 30 条"的第三种形状:探针自己坏了,没人知道)。 * * 所以: * - `true` 可用(前提成立 → 依赖它的静态判据**到期**,必须处理); * - `false` 不可用(探针**确实跑成了**,结论是没有目标); * - `'unknown'` 拿不准(**命令在但跑不成**:超时、非零退出、抛异常)→ **按到期处理**。 * * # 这条设计当场抓到了什么(写下来,因为它是"闸门锈死"的实证) * * 换三值之后第一次运行就报了 `probe=unknown` —— 一查:探针里调的是 `execFileSync`, * 而这个文件 import 的是 `spawnSync`(**名字根本没定义**)。也就是说 * **探针从写下的那天起一次都没跑成过**,而旧的二值设计把 `ReferenceError` * 连同"没有设备"一起吞掉、统一报成"设备不可用"——**机制在、闸门从来没开过, * 而它看起来完全健康**。三值把它变成了一声明确的红:探针自己坏了,必须有人看一眼。 * * 另外,`'unknown'` 只留给"命令在、但跑不成";**所有候选都不存在**(本机没装 hdc) * 是可判的事实(没有工具就不可能有设备),报 `false`,否则没装 SDK 的机器会天天假红。 * 拿不准就红,让人看一眼 —— 这条判断比"猜一个"便宜得多。 * * 测试用 `AGENTMAIL_PROBE_DEVICE=ok|none|unknown` 覆盖(判据自检要用: * 没有这个开关就没法验证"unknown 会不会红")。 */ const PROBES = { device: { // 前提写成"**本工作区**能装能点设备",不是"机器上有设备"(pi 2026-09-14): // 同一台机器上设备对 gui-lab 那条通道可用、对我这条不可用 —— 写"机器上有设备"的话, // 探针变真会让 5 条判据同时红,而**我修不了**(要写 /run、/var/log,在工作区外)。 // 写成"本工作区能装能点",红就只在我能动手时出现。 desc: '本工作区能装、能点设备(hdc 服务健康 + 看得到目标)', need: [ '设备/模拟器要在本工作区里起得来:模拟器启动要写 /run/harmony-emulator.pid 与 /var/log/harmony-emulator.log', 'hdc 的**服务端**要健康(`hdc -m` 在跑);服务端起不来时 `list targets` 也会打印 [Empty],那是"拿不准"不是"没设备"', '要有 hdc 目标;能装上 hap 并能点(hvigorw assembleHap + hdc install)' ], run() { const override = process.env.AGENTMAIL_PROBE_DEVICE; if (override === 'ok') return true; if (override === 'none') return false; if (override === 'unknown') return 'unknown'; /* * # 候选清单的权威性(pi 2026-09-14 §2:硬编码清单今天咬了两次) * * 上一版在**固定两条路径**里找 hdc,全不在就判"本机没装 → 没有设备"(false)。 * 这与 build.sh 在固定几个根里找 SDK 源码、第一个命中就用,是**同一个形状**: * 硬编码清单 + "命中/未命中"当真值。那次命中了 /root/ha-test 的老 checkout, * 于是把错误结论当成了事实。所以这里: * ① 候选来源写清楚(下面 TOOLCHAIN_ROOT 是华为命令行工具的**文档安装根**); * ② **工具链根在、里面却没有 hdc** → `unknown`(不是"没装",是"装了但长得不一样"); * ③ `hdc` 也走 PATH(spawnSync 自己会查 PATH),不再只认那一条写死的路径。 */ const TOOLCHAIN_ROOT = '/opt/huawei/command-line-tools'; const sdkHdc = `${TOOLCHAIN_ROOT}/sdk/default/openharmony/toolchains/hdc`; const candidates = [sdkHdc, 'hdc']; let ranOnce = false; // 至少有一条命令**跑成过**(哪怕是"没有目标") let sawEmpty = false; let anomaly = ''; // 命令在,但跑不成(超时/非零退出/…)—— "拿不准" let allMissing = true; // 所有候选都不存在 for (const bin of candidates) { const r = spawnSync(bin, ['list', 'targets'], { encoding: 'utf8', timeout: 15000 }); if (r.error && r.error.code === 'ENOENT') continue; allMissing = false; if (r.error) { anomaly = r.error.code || String(r.error.message || r.error); continue; } if (r.status !== 0) { anomaly = `exit ${r.status}`; continue; } ranOnce = true; const t = (r.stdout || '').trim(); if (t && !/\[Empty\]/.test(t)) return true; // 明确可用 sawEmpty = true; // "没有目标"(下面还要问服务端健不健康) } /* * `[Empty]` **不等于**"没有设备"(pi 2026-09-14 §1)。 * * 它至少混三种原因:① 真没有设备;② **hdc 自己的服务端起不来** * (socket/权限 —— 而"要写工作区外的目录"正是我这轮撞上的限制); * ③ 被打印成空集的权限拒。所以先证服务端健康,才敢把空集当成"可判的没有": * 服务端进程在(`hdc -m`,含它在别的路径下)→ 空集是可判事实 → false; * 服务端找不到 → 空集可能正是"探针自己被环境弄坏了" → unknown(红)。 */ if (ranOnce && sawEmpty) { return hdcServerLooksHealthy() ? false : 'unknown'; } if (anomaly) return 'unknown'; // 命令在、跑不成 → 拿不准 if (allMissing) { // 工具链根在、里面却没有 hdc → "装了但长得不一样",不是"没装" return existsSync(TOOLCHAIN_ROOT) ? 'unknown' : false; } return 'unknown'; } } }; /** * hdc 的服务端是不是在跑。只读 /proc//cmdline(不依赖 `ps` 也不起子进程 * —— 探针自己不该再引入"命令跑不起来"这种不确定性。 * 判据:命令行里第二个参数是 `-m`(hdc 的 server 模式)。 */ function hdcServerLooksHealthy() { let pids = []; try { pids = readdirSync('/proc').filter(d => /^\d+$/.test(d)); } catch { return false; } for (const pid of pids) { try { // 走 lib/read.mjs 的 prose(判据目录不许裸 readFileSync —— 那条判据也管这里) const cmd = prose(`/proc/${pid}/cmdline`).split('\0').filter(Boolean); if (cmd.length && /(^|\/)hdc$/.test(cmd[0]) && cmd.includes('-m')) return true; } catch { /* 进程刚退出 / 没权限读 —— 换下一个 */ } } return false; } /** 探针结论 → 是否等于"前提成立(到期)" */ const probeIsDue = v => v === true || v === 'unknown'; /** 只能验形态的判据:文件 + 为什么只能静态 + 到期前提 */ /* * 探针自检(`--probe-selftest`):它判的是**判据自己的分辨力** —— * "unknown 到底会不会红"。没有这条,`probeIsDue` 哪天被改成 `v === true` * 也没人会发现,而那正是"闸门锈死"的写法。 */ if (process.argv.includes('--probe-selftest')) { const cases = [ ['ok 可用 → 到期', true, true], ['none 不可用 → 不到期', false, false], ['unknown 拿不准 → 到期(必须红)', 'unknown', true], ]; let bad = 0; for (const [what, value, want] of cases) { const got = probeIsDue(value); console.log(`${got === want ? 'ok ' : 'RED '} ${what}(probeIsDue(${JSON.stringify(value)}) = ${got})`); if (got !== want) bad++; } process.exit(bad ? 1 : 0); } const STATIC_ONLY = [ ['test/harmony-nav.test.mjs', '底栏结构/命中区常量/挂载关系:`.ets` 要 hvigorw 才能编译、要设备才能点', 'device'], ['test/harmony-appearance.test.mjs', '壁纸/令牌/遮罩渲染:观感与运行期换肤要设备', 'device'], ['test/harmony-logic.test.mjs', '页面状态机与文案:`.ets` 状态要跑起来才算数', 'device'], ['test/cross-client-theme.test.mjs', '跨端令牌与玻璃分工:一端是 `.ets`,只能静态对齐', 'device'], ['test/appearance-defaults.test.mjs', '默认值契约里 `.ets` 那半:运行时行为要设备', 'device'], // P4c 同批的两条:判的是 `.ets` 里的页面/组件,本机没有设备也没有模拟器 ['test/harmony-admin.test.mjs', '用户管理页:`.ets` 页面要 hvigorw 才能编译、要设备才能点(本机两者都没有)', 'device'], ['test/harmony-imageprep.test.mjs', '图片上传链:要 `@ohos.multimedia.image` + 相册 + 服务端,三样本机都没有', 'device'] ]; for (const [file, , probe] of STATIC_ONLY) { if (!SUITE.some(([f]) => f === file)) { console.error(`✗ 静态判据登记里的 ${file} 不在套件清单里(登记要跟着套件走)`); process.exit(1); } if (!PROBES[probe]) { console.error(`✗ ${file} 的到期前提 \`${probe}\` 不是已知探针(UNBLOCK 必须是可机检的前提,不是一句陈述)`); process.exit(1); } } const probeResults = {}; for (const [, , probe] of STATIC_ONLY) { if (probeResults[probe] === undefined) probeResults[probe] = PROBES[probe].run(); } let selfCheckFailed = false; /* * 自检 4(pi 2026-09-15):**到期闸不许把汇总和其它红一起吃掉**。 * * 起因:到期块原先在报文末尾 `process.exit(1)`,而它在汇总之前 ⇒ 只要它红, * `RESULT phase=` 行、欠账余额、条数不符、**所有其它红**都不打(pi 实测 `harmony-presets` 那条真红一次没显示)。 * 修法是"记账 + 走完汇总",但**修法本身没有判据钉着** —— 谁改回去都不会被拦。这就是那条判据。 * * ⚠️ 它验的是**套件自己的源码形状**,不是"跑起来真的没被吃": * 标签只覆盖"源码里那两种写法不在了",不覆盖"运行时一定同现"。想验后者要跑两次完整套件,成本高。 */ try { const selfSrc = prose(fileURLToPath(import.meta.url)); /* * ⚠️ 锚点**不许可自匹配**(pi 2026-09-15 实测踩到):原来这两个锚点写的是 * `'let dueFailed = false;'` / `'const probeSummary'`,而**这两串字本身就出现在下面这段自检代码里**。 * 把自检块**上移**到到期闸之前以后,`indexOf` 第一次命中的就是**自检自己那两行** ⇒ 扫的区间变成 * 自检自己的两行 ⇒ 真闸从没被扫过 ⇒ 它**空转报 ok**(比原来更糟:连失效都不响)。 * 所以:① 锚点取 `lastIndexOf` —— 真闸在这段自检**之后**,而上面两串"标记"必然也出现在自检代码里(作为 indexOf 的实参), * `indexOf` 会先命中自检自己;`lastIndexOf` 才指向真闸; * ② 再加一道自匹配断言(见下 `selfSrc.indexOf` 那条),宁可红也不许空转。 */ /* * ★ 锚点在**运行时拼**(dsh 2026-09-15 的建议):搜索键不许以字面量出现在搜索者自己的源码里。 * 上一版用 `lastIndexOf` 治的是**位置**("可执行那处在更后面"),依赖"字面量只出现两次且顺序稳定" —— * 而位置是会被搬动的(这次就是被搬了才出的第 7 例)。拼出来之后,源码里根本不存在那串字, * 自检搬到哪儿都不会把自己当锚点;下面那条"区间里不许有 selfSrc.indexOf"的断言继续做兜底。 */ const GATE_START = 'const dueStatic = STATIC_ONLY' + '.filter'; const GATE_END = 'const probeSummar' + 'y = '; // 同样拼着写:这串字本身也是搜索键 const from = selfSrc.indexOf(GATE_START); const to = selfSrc.indexOf(GATE_END); const GATE_MARKERS_OK = from >= 0 && to > from; /* * ⚠️ 必须**先去注释与字符串**再找 `process.exit(`:这条自检自己的报错文案里就有那串字, * 不去掉的话它会**指着自己报红**(我第一次跑就是这样 —— 与 `@ohos`/`toISOString` 那两次同族: * 扫描器咬到自己的文档)。规矩不变:**不靠挪扫描口径解决,靠去掉不该扫的部分**。 */ const gate = GATE_MARKERS_OK ? stripComments(selfSrc.slice(from, to)) : ''; const probs = []; if (!gate) { probs.push('找不到到期闸区块(标记被改动)⇒ 这条自检**自己失效**,必须红 —— 不许静默通过'); } else if (gate.includes('selfSrc.indexOf')) { probs.push('扫描区间里含**自检自己的代码**(锚点自匹配)⇒ 它只会空转报 ok,必须红'); } else { if (/process\.exit\s*\(/.test(gate)) { probs.push('到期闸区块里又出现了 `process.exit()` ⇒ 它会在汇总之前退出,把汇总与其它红一起吃#掉'); } if (!/&&\s*!dueFailed/.test(selfSrc)) { probs.push('汇总分支缺 `&& !dueFailed` 守卫 ⇒ 到期闸红时可能反过来误报"全部通过"(修一处引坏一处)'); } } if (probs.length) { console.error('✗ 套件自检 4(到期闸不许吃掉汇总):'); for (const x of probs) console.error(` - ${x}`); selfCheckFailed = true; } else { console.log('ok 4 - 到期闸是**记账**而不是 exit(汇总与其它红不会被它吃掉)'); } } catch (e) { console.error(`✗ 套件自检 4 跑不了:${e.message || e}`); selfCheckFailed = true; } /* * 自检 5(pi 2026-09-15 找到的总数行 bug):**`ran` 不许超过 `files`** —— 每个文件只该计一次。 * * 起因(真发生过):累加块在红绿分支**之外**和绿分支 `else` 里**各有一份**, * 于是每个 exit 0 的文件被计**两遍** ⇒ `ran=49`(而 files=25)、`checks` 约 2 倍。 * 它**自洽**,所以读数检查不出来:`pass + fail == checks`(791+1=792)两边一起虚高,恒等式照样成立。 * 而 `RESULT files=…` 这行本是为"总数要有权威来源"才加的 —— **它自己就是错的**。 * * 判据形状:`ran <= files` 是**结构性的上限**(一个文件最多贡献一个 `ranFilesCount`), * 不需要知道任何自报条数就能判。`checks` 没有这种上限(条数是文件自己报的), * 所以别在这里给 `checks` 编一个 —— 那会变成"用猜数字的判据"。 * * ★ **这个上界对"每一个按文件累加的计数器"都成立**(pi 2026-09-15 指出我只判了两个): * 它们一共三个 —— `ranFilesCount` / `totalFail` / `unreportedFiles`。 * 我漏了 `unreportedFiles`,而它当时**真的仍然计两次**(我上一次"修好了"的那个提交里, * 删了 `totalTests` 那三行却把紧跟的 `unreportedFiles += 1` 留在原地)。 * ⇒ 教训:**"每个文件只该计一次"是一条对整族计数器都成立的性质,不是对某一个**; * 只给其中两个判上界,第三个就永远没人管。 * (`totalFail <= checks` 只用 "fail 是 checks 的子集",比 `<= files` 更紧且同样结构性。) * * ⚠️ 这条自检管的是**总数的自洽性**,不是"条数对不对":`checks` 的正确值只有 * 逐文件核得出(这正是这个 bug 难发现的原因)。它拦的是"又一次搬动只加不删"。 */ try { const probs = []; if (ranFilesCount > SUITE.length) { probs.push(`ran=${ranFilesCount} > files=${SUITE.length} —— 每个文件只该计一次,` + `超出来说明**累加块有不止一份**(搬动时只加没删)。` + `修法:数 \`ranFilesCount\` 的**写**有几处(**不一定是 \`+=\`** —— 见下面的源码扫描,` + `只按 \`+=\` 搜会漏掉 \`= ranFilesCount + 1\` 这种等价写法)。`); } if (totalTests < totalFail) { probs.push(`checks=${totalTests} < fail=${totalFail} —— fail 是 checks 的子集,这不可能成立`); } /* * ★★ 这里原来是一条**源码文本扫描**("四个计数器各只许一处写"),连同它的自检一起删掉了。 * * 为什么删(pi 2026-09-15 建议,我采纳):那条扫描器自己长出了**五轮**毛病 —— * 注释 → 字符串 → `= X + n`/`X++` → 锚钉行首 vs 语句起点 → * 正则字面量里的引号(**最后一轮是假绿方向**:会吞掉真写,把"其实有两处写"数成 1, * 而"第二处写"正是它唯一存在的理由)。每轮我都修好了那个实例,**族还在**。 * * ⇒ 改成**投影**后(见上面 `reportedRecords`),那四个计数器**没有"写"可言**, * 扫描器**没有对象可扫** —— 整段删掉,`stripStrings`/`stripComments` 的引入也一并去掉。 * (`stripStrings` 本身留在 `read.mjs`:它是通用工具,只是**这条判据**不再用它 —— * 所以这里连 import 也去掉了。**留着不用的 import 就是下一次"看着像还在用"的源头。**) * * ⚠️ 但**别把这次改动说成"不可能再计两次"**(pi 的原话是"写不出来",我实测后认为过强): * `records.push` 仍然可以被复制粘贴两次。准确的说法是: * 重复不再表现为"某个计数器被加两次",而表现为"**同一条记录出现两次**" ⇒ * 于是它**能被行为判据抓住**,而不是只能靠猜文本。 * **这是"文本判据 → 行为判据",不是"可能错 → 不可能错"。** */ if (records.length !== phaseWillRun.length) { const seen = new Map(); for (const r of records) seen.set(r.file, (seen.get(r.file) || 0) + 1); const dup = [...seen].filter(([, n]) => n > 1); probs.push(`跑出的记录数 ${records.length} ≠ **本相位实际会跑**的文件数 ${phaseWillRun.length}` + `(套件共 ${SUITE.length} 条,本相位跳过 ${skippedByPhase.length} 条构建相位判据)—— ` + `每个文件**恰好一条**记录(这是"每个文件只该计一次"的**行为**版本;` + `原来那条是扫源码文本的,已删,理由见上)。\n` + (dup.length > 0 ? ` 重复的文件:${dup.map(([f, n]) => `${f}×${n}`).join('、')}` + `⇒ **push 了两次**(不是漏跑)。\n` : ` 没有重复文件 ⇒ 是**漏了**或**多跑了**,不是 push 两次:检查 SUITE 与循环。\n`) + ` ⚠️ 期望值用 \`phaseWillRun\`(本相位不被跳过的那些)而**不是** \`SUITE.length\`:` + `install 相位会跳过构建相位判据,拿套件总数比就是**假红**` + `(这条命令正是 \`install.sh --check\` 跑的那条)。`); } if (ranFilesCount > SUITE.length) { probs.push(`ran=${ranFilesCount} > files=${SUITE.length} —— 自报条数的文件比文件总数还多,` + `不可能成立。\n` + ` ★ 这条现在**冗余**于上面那条(投影下 ranFilesCount 就是"checks 非空的记录数",` + ` 有 ≤ 记录数 ≤ 文件数 的构造性保证)。**留着当"投影被改坏"的探测器**:` + ` 哪天有人把它换回就地累加,这条会立刻响。`); } if (totalTests < totalFail) { probs.push(`checks=${totalTests} < fail=${totalFail} —— fail 是 checks 的子集,这不可能成立`); } if (probs.length) { console.error('✗ 套件自检 5(总数行的自洽性):'); for (const x of probs) console.error(` - ${x}`); selfCheckFailed = true; } else { /* * ★ 报文只许写**真的判过**的东西(pi 2026-09-15 抓到我把没判的写上去过): * 原来这句是 `ran ≤ files,unreported ≤ files,fail 是 checks 的子集` —— * 而**全文件没有任何地方断言 `unreported ≤ files`**(`grep "unreportedFiles >"` 零命中)。 * 我测出那条上界是空的、换成了源码处数扫描,**却把半句留在成功报文里** ⇒ * 报文在**替一条已经被我判定无效的判断作证**。 * 形状:**"读数器替一件事作证"落在自检自己身上** —— 而这条自检存在的理由, * 正是"`RESULT` 那行自作证"。**读数器也要被读数。** * * ★ 本条的判据形状**在 pi 2026-09-15 的建议后换过**:原来是"扫自己的源码, * 数四个计数器各有几处写"(文本判据,长出了五轮毛病); * 现在是"**记录数 == 本相位实际会跑的文件数**"(行为判据,见上面 `reportedRecords`)。 * 报文如实说它现在由谁判 —— 改了判据却留着旧文案,就是又一次"说法与实现不一致"。 * (**这句话我这轮又踩了一次**:改成 `phaseWillRun` 之后报文还写着"== 文件数", * 而 install 相位 `files=25` 里只有 23 条会被跑 ⇒ 报文本身就成了假话。) */ console.log(`ok 5 - 总数行自洽(ran=${ranFilesCount} ≤ files=${SUITE.length},` + `fail 是 checks 的子集;四个计数器由 ${records.length} 条记录**投影**而来 —— ` + `判的是"记录数 == **本相位实际会跑的** ${phaseWillRun.length} 条"` + `${skippedByPhase.length ? `(套件 ${SUITE.length} 条里本相位跳过 ${skippedByPhase.length} 条)` : ''}` + `这个**行为**,不是"源码里 \`+=\` 有几处")`); } } catch (e) { console.error(`✗ 套件自检 5 跑不了:${e.message || e}`); selfCheckFailed = true; } /* * pi 2026-09-15:这块原先在报文末尾 `process.exit(1)`,而**它在汇总之前** ⇒ * 只要到期闸红了,`RESULT` 行、欠账余额、条数不符、以及**所有其它红**都不会打出来。 * 实测:`harmony-presets` 实报 6 / 登记 5 这条真红一次都没显示过。 * 形状:**一道闸把不属于它的证据一起吃掉**。修法不是"少红",是**记账 + 继续走完汇总**。 */ let dueFailed = false; const dueStatic = STATIC_ONLY.filter(([, , probe]) => probeIsDue(probeResults[probe])); const unknownProbes = Object.entries(probeResults).filter(([, v]) => v === 'unknown').map(([k]) => k); if (dueStatic.length > 0) { const firstUnknown = unknownProbes.includes(dueStatic[0][2]); console.error(firstUnknown ? `\n✗ 探针**跑不了**(${PROBES[dueStatic[0][2]].desc})—— 拿不准就按到期处理,别让闸门锈死:` : `\n✗ 静态判据**到期**了:${PROBES[dueStatic[0][2]].desc} 现在是成立的 ——`); for (const [file, why, probe] of dueStatic) { console.error(` - ${file}(到期前提:${PROBES[probe].desc};当初只能静态的原因:${why})`); } console.error( ' 这些判据当时只能验形态。前提成立后必须做其中一件(别默默留着):\n' + ' a) 改成**行为判据**(真跑一遍/真点一次),静态那条降级或删掉;\n' + ' b) 明确写"为什么仍然只能静态"(如设备能编译但点不了),并改换一个更准的到期前提。\n' + ' 这是"暂时"的到期机制:它的作用就是不等谁想起来。' ); /* * 到期报文自带**要人做什么**(pi 2026-09-14 §2): * 这条红第一次出现时,最可能的结局是"被当成噪音消掉"——因为看的人不知道要放行什么。 * 前提写的是"**本工作区**能装能点",所以这里把工作区外的门槛逐条列出来。 */ const need = PROBES[dueStatic[0][2]].need || []; if (need.length) { console.error(' 要让它变成行为判据,需要先在本工作区打通:'); for (const n of need) console.error(` · ${n}`); console.error(' 这些都在工作区外(需要人放行或提权)—— 所以这条红**不要求你现在修**,' + '要求的是:别把它当噪音,并在打通后回来把静态那条升级掉。'); } dueFailed = true; } /* * 汇总里"欠账余额"和"探针是否健康"是两个不同的数字(pi §2): * static=5 —— 还欠着 5 条只能验形态的判据; * probe=ok —— 探针自己是好的(unknown 说明闸门可能锈死了,得人看一眼)。 */ const probeSummary = unknownProbes.length ? 'unknown' : 'ok'; if (skippedByPhase.length) { console.log(`相位=${PHASE}:本相位不判 ${skippedByPhase.length} 条**构建相位**判据(${skippedByPhase.join('、')})` + `—— 它们问的是"产物是不是从当前源码来的",安装相位问的是"已产出的产物能不能装";` + `安装相位判它只能读**产物自证**(见 deploy/install.sh 的前置检查)。`); } /* * 欠账余额打进**常态可见**的这一行(pi 2026-09-14 裁定 §1/§3): * "跳过"若不可数就等于不显形 —— 而 `go test` 跑通时不打印包的输出, * 所以 Go 那边的 Skip 余额必须在**这边**可见。两边读**同一个**登记(docs/DEBTS.json), * 于是"三笔欠账一处看全"且有余额、有到期。 */ /* * 欠账余额:**登记值与实测值一起打**(pi 2026-09-14 裁定 §1)。 * * 为什么不能只打登记值:`docs/DEBTS.json` 是**手可编辑的副本**,而"可见的那个数字就是副本" * 恰好违反我自己写进 §12 的那句 —— 比较的对象必须是权威来源,不是手边那份会过期的副本。 * 所以这一行同时给出: * · `registered`:登记值(副本,谁都能改); * · `measured`:**本相位能实测的部分**(这里有权威来源的只有 static:`STATIC_ONLY.length`); * · 本相位测不了的(Go 侧那几笔)**明说由谁比对**,不冒充已实测。 * 不等即红:下面这条判据就是这一行(static 的登记值必须等于实测值)。 */ const DEBTS_PATH = join(HERE, '..', '..', '..', 'docs', 'DEBTS.json'); let debtsLine = ''; let debtRegStatic = null; try { const led = JSON.parse(prose(DEBTS_PATH)); const total = led.debts.reduce((n, d) => n + d.count, 0); const items = led.debts.map(d => `${d.id}:${d.count}`).join(','); const regStatic = led.debts.find(d => d.id === 'static-criteria'); debtRegStatic = regStatic ? regStatic.count : null; const measuredStatic = STATIC_ONLY.length; /* * 子集关系必须**打进字符串本身**(pi 2026-09-14 裁定 §2)。 * 上一版打的是 `debts=9(registered) measured_static=5==登记 ✓`:读的人先看到 9≠5, * 而"本相位只有 static 有权威实测"只写在**信里**、不在那一行里 —— 于是那一行 * **没有一个字在判它可读**。理由与我写进 §12 的那条同源: * **余额里任何需要靠注释才能读对的地方,就是下一个假绿。** */ const other = total - (debtRegStatic || 0); const staticNote = debtRegStatic === null ? 'static=登记里没有这一项 ✗' : debtRegStatic === measuredStatic ? `static-only=${measuredStatic}==登记 ✓` : `static-only=${measuredStatic}≠登记${debtRegStatic} ✗`; const env = led.debts.filter(d => d.kind === 'env').reduce((n, d) => n + d.count, 0); debtsLine = ` debts=${total} (${staticNote} / other=${other} by go-test / env=${env} 等环境或人)(${items})`; } catch (e) { debtsLine = ` debts=登记读不到(${e.code || e.message})`; } /* * 变异体计数**也进这一行**(pi 2026-09-15):此前它只活在信里,于是同一个量 * 换过 40/48/58/41 四种说法,而"job 集合是什么"没有定义 —— 复核方拿到的是一个 * "我复算不出你那个数、而且你的报数方法也不在场"的目录。 * ⇒ 口径写死在 `test/mutants/summary.py`(唯一权威),这里只**播报**它, * 并顺着同一条"要提醒人的东西必须走默认路径"的规则。 * 跑不起来(summary.py 不在、python3 不在)**不静默** —— 明说"没跑", * 免得下一个人把"这行没提"当成"没问题"。 */ let mutantsLine = ''; try { // ★ 路径要 join(HERE, …):`HERE` 是 `test/`(不是 `client/electron/`)—— // 我第一版写成相对 `client/electron/` 的 'test/mutants/summary.py', // 于是 python 报 "can't open file …/test/test/mutants/summary.py"(status=2), // 而这里只看到"没打出 RESULT"。**错误信息被吞掉**正是这行注释要提醒的事: // 播报失败时要把 stderr 一起说出来,别只说"未知"。 const sp = spawnSync('python3', [join(HERE, 'mutants', 'summary.py')], { cwd: HERE, encoding: 'utf8', timeout: 30000 }); const m = /(RESULT mutants=\d+ ran=\d+ skipped=\d+ on_new_criteria=\d+)/.exec(sp.stdout || ''); mutantsLine = m ? ` ${m[1]}` : ` mutants=(summary.py 没打出 RESULT:status=${sp.status} ${(sp.stderr || '').trim().split('\n').slice(-1)[0] || ''})`; } catch (e) { mutantsLine = ` mutants=(跑不了 summary.py:${e.code || e.message})`; } console.log(`RESULT phase=${PHASE} static=${STATIC_ONLY.length}${debtsLine} probe=${probeSummary}` + (unknownProbes.length ? `(探针跑不了:${unknownProbes.join('、')} —— 已按到期处理)` : '(只能验形态的判据:到期前提成立就自动变红)') + mutantsLine); console.log(`\n========== 判据汇总 ==========`); /* * 判据**条数**的权威来源(pi 2026-09-15)。 * 格式与其它 `RESULT` 行同族:机器可读、一行、**套件自己打的**。 * * ⚠️ 单位要写清:`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` 现在是**真不变量**(两边各自独立测得,见 `totalFail` 的注释)—— * 它此前是定义式(`pass` 由 `checks - fail` 派生),所以**形同虚设**: * 单位取成 `# pass` 时 `checks` 与 `pass` 一起少算,那个等式照样成立。 */ /* * ★ 这一行的**三个"坏"计数单位不同**,别混着读(pi 2026-09-15 指出缺一格): * * · `fail=N` —— **文件自报的失败条数**(`# fail N` / `RESULT fail=N` 累加)。 * · `red=N` —— **红的判据文件数**(`reds`,含**运行器自己**推的那些,见下)。 * · `broken=N` —— 跑不起来(exit 非 0 / 无输出)的文件数。 * * 为什么必须把 `red` 显式打出来:那 3 条"自报条数 ≠ 登记数"的判据是**运行器自己**推的 * (`reds.push`),根本不经过文件 ⇒ **进不了 `fail`**。实测过 `fail=1` 而"红的判据(4/25)", * 于是 `fail=1` 被读成"只有一处坏了"。两格都在,读的人才不用自己猜单位。 */ const totalsLine = (verdict) => `RESULT files=${SUITE.length} ran=${ranFilesCount} ` + `checks=${totalTests} pass=${totalTests - totalFail} fail=${totalFail} ` + `red=${reds.length} broken=${brokens.length} unreported=${unreportedFiles} verdict=${verdict}`; if (reds.length === 0 && brokens.length === 0 && !dueFailed && !selfCheckFailed) { // 报**本相位实际跑了**几条,不报清单长度 —— 上面刚写完"不能声称自己做过的事", // 这里如果打 SUITE.length 就是在同一行里犯同一个错(2 条构建相位判据被跳过了)。 const ranFiles = SUITE.map(([f]) => f).filter(f => !skippedByPhase.includes(f)); console.log(`全部通过(${ranFiles.length} 个判据文件:` + `${ranFiles.map(f => f.replace('test/', '').replace('.test.mjs', '')).join('、')}` + `${skippedByPhase.length ? `;另有 ${skippedByPhase.length} 条属构建相位,本相位不判` : ''})`); console.log(totalsLine('green')); process.exit(0); } // broken 先报:它比红更严重(红是"判据说不成立",broken 是"判据没说话") if (brokens.length) { console.error(`跑不起来的判据(${brokens.length}/${SUITE.length})—— 不是红,也不算过:`); for (const b of brokens) console.error(` - ${b}`); } if (reds.length) { console.error(`红的判据(${reds.length}/${SUITE.length}):`); for (const r of reds) console.error(` - ${r}`); } if (dueFailed && reds.length === 0 && brokens.length === 0) { console.error('另外:静态判据**到期**(报文见上)—— 它现在**不再吃掉**上面的汇总与其它红。'); } console.log(totalsLine('red')); process.exit(1);