/** * 判据目录自身的卫生:**读文本必须走 `test/lib/read.mjs` 的两个具名入口**。 * * # 为什么这条判据存在(pi 2026-09-14 §4) * * 规范里写着"判代码读剥离版(`code`)、判理由/文档读原文(`prose`)", * 我 P5 写过一次、当天又踩了一次:那条断言读的是**原文**,而它要找的标识符 * 恰好出现在一段解释性注释里 → 误报。**第二次犯规说明问题不在记性,在形态**: * 靠人记得执行的规范一定会有下一次。 * * 所以把"用哪个读取器"从**记忆**变成**代码里的一个词**,并且可被检查: * - `code(path)` —— 剥掉注释;判"代码里有没有这个调用/这个值"; * - `prose(path)` —— 原文;判"注释/文档里写了什么"; * - `bytes(path)` —— 二进制(安装包等)。 * * # 判据 * * 判据目录(`test/**` 里跑的判据 + `run-all.mjs`)中**不得出现裸 `readFileSync`**, * 唯一例外是 `test/lib/read.mjs` 自己。`test/manual/**` 是人工脚本、不是判据,不在范围内。 * * 附两条自检:读取器本身要真的剥注释(否则 `code` 退化成 `prose` 这条判据就废了)、 * 以及探测器要能认出裸调用(否则"都没有"与"探测器坏了"结果一样)。 */ import assert from 'node:assert/strict'; import { readdirSync, unlinkSync, writeFileSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; import { code, prose, stripComments } from './lib/read.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const RELECTRON = join(HERE, '..'); // test/ 的上一级就是 client/electron const SELF = join(HERE, 'lib', 'read.mjs'); /** 判据文件清单:`test/**` 下会跑的判据 + 编排器;不含 lib/ 与 manual/ */ function criteriaFiles(dir = HERE, out = []) { for (const e of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, e.name); if (e.isDirectory()) { if (e.name === 'lib' || e.name === 'manual' || e.name === 'node_modules') continue; criteriaFiles(p, out); } else if (/\.(test\.mjs|test\.ts|test\.tsx|mjs)$/.test(e.name) && !e.name.endsWith('.d.ts')) { out.push(p); } } return out; } /** 探测器:一段源码里有没有裸 readFileSync */ const BARE = /\breadFileSync\s*\(/; test('探测器自检 + 读取器自检', () => { // ① 探测器能认出裸调用(否则"都没有"与"探测器坏了"分不开) assert.equal(BARE.test("const s = " + "readFile" + "Sync(p, 'utf8');"), true); assert.equal(BARE.test('const s = prose(p);'), false); // ② code 真的剥注释、prose 不剥 —— 这条是整套用法的地基: // 若 code 退化成 prose,那么"读剥离版"的规范就变成一句空话,而且没人会发现。 const probe = join(RELECTRON, 'test', '_reader_probe.tmp.ts'); // 注:这个探针文本**故意拼接**而不是写字面量 —— 否则本判据自己会被自己判红 // (它扫的就是"文本里有没有这个写法",判据文件也在扫描范围内)。 writeFileSync(probe, "const REAL = 1; // " + "readFile" + "Sync( 注释里的假调用\n/* allowed-once */\n"); try { assert.ok(!code(probe).includes('allowed-once'), 'code() 必须剥掉块注释'); assert.ok(!code(probe).includes('假调用'), 'code() 必须剥掉行注释'); assert.ok(code(probe).includes('REAL'), 'code() 要保留真代码'); assert.ok(prose(probe).includes('allowed-once') && prose(probe).includes('假调用'), 'prose() 必须保留注释'); } finally { unlinkSync(probe); } }); test('★ 判据目录里不得出现裸 readFileSync(必须走 code/prose/bytes)', () => { const offenders = []; for (const f of criteriaFiles()) { if (f === SELF) continue; // 读取器的实现自己当然要用它 const src = prose(f); // 扫的是"文本里有没有这个写法",所以读原文 if (BARE.test(src)) { const line = src.split('\n').findIndex(l => BARE.test(l)) + 1; offenders.push(`${relative(RELECTRON, f)}:${line}`); } } assert.deepEqual(offenders, [], `这些判据文件里还在裸用 readFileSync:\n ${offenders.join('\n ')}\n` + " 改用 test/lib/read.mjs 的具名入口:\n" + " · code(path) —— 剥掉注释。判「代码里有没有这个调用/这个值」时用它(默认选它);\n" + " · prose(path) —— 原文。判「注释/文档里写了什么」时用它;\n" + " · bytes(path) —— 二进制(安装包等)。\n" + " 为什么不能裸用:读原文去判代码,会被解释性注释骗(同一个坑已经踩过两次)。"); }); /** * ★ 用到 `lib/read.mjs` 的导出名就必须真的 import(我这轮在三个文件里各犯过一次)。 * * 形状一模一样:`code(...)` / `prose(...)` 写下去,import 里却只有另一个 —— * 于是在**跑起来的那一刻**才炸 `ReferenceError`,而它抛在判据自己身上, * 看起来像"这条判据红了",不像"判据写错了"。dsh 桥那边也栽过同一形状 * (`MODE_FULL` 没 import,而且被 `tsc | tail` 的退出码骗过)。 * * 判据做法:把每个判据文件里出现的 `code(`/`prose(`/`bytes(` 收集起来, * 与它从 `lib/read.mjs` 实际 import 的名字比对;缺一个就红,并点名文件与名字。 * **例外**:文件里自己定义了同名函数(本地实现)时不算缺 —— 但那种情况要显式声明。 */ test('★ 用到 code/prose/bytes 就必须 import(不许靠运行时才发现)', () => { const EXPORTS = ['code', 'prose', 'bytes']; const problems = []; const SELF_PATH = fileURLToPath(import.meta.url); for (const f of criteriaFiles()) { // 它自己的源码里就写着 code/prose/bytes 这几个名字(EXPORTS 列表),跳过自己 if (f === SELF_PATH) continue; const src = prose(f); if (src.includes("from './lib/read.mjs'") || src.includes("from '../lib/read.mjs'")) { const m = /import \{([^}]*)\} from '\.\.?\/lib\/read\.mjs'/.exec(src); const bound = new Set((m ? m[1] : '').split(',').map(x => x.trim().split(/\s+as\s+/).pop()).filter(Boolean)); for (const name of EXPORTS) { if (new RegExp(`\\b${name}\\(`).test(src) && !bound.has(name)) { problems.push(`${relative(RELECTRON, f)} 用了 ${name}(…) 但没 import(已 import:${[...bound].join('、') || '无'})`); } } } else { for (const name of EXPORTS) { if (new RegExp(`\\b${name}\\(`).test(src)) { problems.push(`${relative(RELECTRON, f)} 用了 ${name}(…) 但根本没从 lib/read.mjs import`); } } } } assert.deepEqual(problems, [], `这些判据会以 ReferenceError 的形式红,看起来像"判据失败了",其实是"判据写错了":\n ${problems.join('\n ')}`); }); /** * ★ 判据**必须读自己那棵树**,不许把仓库根硬编码成绝对路径。 * * pi 2026-09-15 实测出的形状(这次长在**判据自己**身上,正是我们前几轮一直在消的那个): * `harmony-arkts.test.mjs` 里写着 `const ROOT = '/home/program/agentmail'`。 * 把带违规的提交检出到别的目录再跑,它**读的仍是 `/home/program/agentmail`** ⇒ * **在一个 import 顺序明显违规的检出上 3/3 全绿**。 * * 两层后果,第二层最糟: * ① 它**永远无法验证任何别的 checkout / CI / 镜像** —— 换个目录不是"红", * 是 `readdirSync` 直接抛(broken),而 broken 证明不了任何判据成立或不成立; * ② 在本机做 worktree 复核时,它会**静默读另一棵树并报绿**。 * **"规则进来了,对象没进来"** —— 判据的逻辑对,对象错。 * * 判据做法:扫判据目录里**真代码**(`code()` 剥注释,否则本文件自己的说明就会误报), * 找形如 `const X = '/绝对路径'` 的仓库根声明。修法照邻居:`join(HERE, '..', '..', '..')`。 */ test('★ 判据不许把仓库根硬编码成绝对路径(必须从本文件位置推)', () => { /* * 例外:**工具链/SDK 的绝对路径是合法的** —— 那些东西本来就不在仓库里, * 推不出来(`TOOLCHAIN_ROOT = '/opt/huawei/command-line-tools'`)。 * 所以按**名字**放行含 `TOOLCHAIN`/`SDK`/`HAP` 的常量,而不是按值的白名单 —— * 值白名单会逼着下一个人为了过判据去改那个路径的写法。 * 另一半保证:仓库**内部**的路径一律不许硬编码,那才是"读错树"的来源。 */ const TOOLCHAIN_OK = /TOOLCHAIN|_SDK|SDK_|HAP_|EMULATOR|HDC/i; const problems = []; for (const f of criteriaFiles()) { const src = code(f); // 只看**真的在赋值绝对路径**的那些行;注释已被剥掉,不会拿说明文字误报 for (const m of src.matchAll(/(?:const|let|var)\s+(\w+)\s*=\s*'(\/[^']*)'/g)) { const [, name, val] = m; const looksLikeRepo = new RegExp(`(^|/)${relative(RELECTRON, f).split('/')[0]}|agentmail`, 'i').test(val) || /PROJECT|REPO|WORKSPACE/i.test(name); if (looksLikeRepo && !TOOLCHAIN_OK.test(name)) { problems.push(`${relative(RELECTRON, f)}:\`${name} = '${val}'\` —— 这是**仓库内**的路径,` + `必须从 \`import.meta.url\` 推(\`join(dirname(fileURLToPath(import.meta.url)), '..', …)\`),` + `否则这个判据读的不是它自己那棵树`); } } } assert.deepEqual(problems, [], `这些判据被钉死在一条**仓库内**的绝对路径上 —— 在别的检出/CI/镜像里,` + `它们要么读错树报假绿,要么直接抛(broken):\n ${problems.join('\n ')}`); }); /** * ★ `stripComments` 必须**保持行号不变**。 * * 块注释自带换行,若整块抹成 `''`,它之后**所有行号整体前移** —— * 而全仓判据都在用 `文件:行号` 定位(grep、编辑器跳转、`git show` 核对)。 * 实测(我自己的 `harmony-arkts` 报违规时):报出 64/47,**真实文件是 80/63**, * 读者第一步就得先猜"这是剥过的还是没剥的"。 * * 判据做法:造一个含多行块注释的样本,断言剥完**行数不变**、且行号仍然对得上; * 再断言注释内容确实被去掉了(别为了保行号把注释留下)。 */ test('★ stripComments 必须保持行号(否则判据报的行号全是错的)', () => { const sample = [ '/*', ' * 多行块注释', ' * 第二行', ' */', 'const a = 1; // 行尾注释', '/* 单行块注释 */', 'const b = 2;', ].join('\n'); const out = stripComments(sample); assert.equal(out.split('\n').length, sample.split('\n').length, 'stripComments 改变了行数 —— 它之后所有行号都会错位'); assert.ok(!out.includes('多行块注释') && !out.includes('行尾注释'), 'stripComments 没把注释去掉'); // 行号对得上:第 5 行仍应是 `const a = 1;` assert.match(out.split('\n')[4], /const a = 1;/, '剥完之后第 5 行不再是原来的第 5 行'); assert.match(out.split('\n')[6], /const b = 2;/, '单行块注释所在的那一行之后,行号错位了'); });