/** * 判据目录自身的卫生:**读文本必须走 `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 } 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 ')}`); });