update
This commit is contained in:
135
tests/unit/main/lint/no-direct-body-read.test.ts
Normal file
135
tests/unit/main/lint/no-direct-body-read.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
// tests/unit/main/lint/no-direct-body-read.test.ts
|
||||
//
|
||||
// 守「笔记 / Markdown body 的读必须走单一入口」这条契约(2026-10-20,r203):
|
||||
//
|
||||
// src/main/utils/body-cap.ts:readBodyWithCap —— 单 fd stat + read,
|
||||
// 5MB 上限,超限抛 TOO_LARGE。
|
||||
//
|
||||
// 为什么这条是安全面(不是风格):fs.stat + fs.readFile 两步走法在 stat 与
|
||||
// read 之间有 TOCTOU 窗口 —— AV 隔离 / OneDrive 重水合 / 用户 atomic-rename
|
||||
// 都可能把文件换成 1GB+ 大文件,5MB 上限失效,主进程 OOM。走单一入口才有
|
||||
// 单 fd 的 size 保护。
|
||||
//
|
||||
// 判定方式:静态扫 src/main/** 里的直接读调用(`readFile(` / `readFileSync(`),
|
||||
// 路径参数**看起来像 .md / note / markdown** 的一律违规 —— 白名单只有两个:
|
||||
// - src/main/utils/body-cap.ts (就是单一入口本身)
|
||||
// - src/main/notes/standalone.ts (scan hot path 特化:块读 + 字节累计
|
||||
// + 早 abort,比通用入口更严)
|
||||
//
|
||||
// 覆盖边界(诚实声明):静态判定只能看出路径**字面**像 .md 的调用。
|
||||
// 变量名完全不沾 md/note 的直接读(例如 `readFile(join(dir, entry))`,
|
||||
// entry 来自 readdir)本守卫**看不见** —— 那类代码要靠 code review。
|
||||
// 守卫的价值是拦住最常见的一手写法:`readFile(join(dir, 'x.md'))`。
|
||||
import { readdirSync, readFileSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
/** 唯一允许直接读 body 的文件(相对仓库根) */
|
||||
const WHITELIST = new Set([
|
||||
'src/main/utils/body-cap.ts',
|
||||
'src/main/notes/standalone.ts',
|
||||
]);
|
||||
|
||||
function walkTs(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) out.push(...walkTs(full));
|
||||
else if (/\.tsx?$/.test(entry) && !entry.endsWith('.d.ts')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 取出 `name(` 之后的成对括号内容 */
|
||||
function argsOf(src: string, openIdx: number): string {
|
||||
let depth = 0;
|
||||
for (let i = openIdx; i < src.length; i++) {
|
||||
const c = src[i];
|
||||
if (c === '(' || c === '[' || c === '{') depth++;
|
||||
else if (c === ')' || c === ']' || c === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return src.slice(openIdx + 1, i);
|
||||
}
|
||||
}
|
||||
return src.slice(openIdx + 1);
|
||||
}
|
||||
|
||||
/** 第一个实参(顶层逗号切分) */
|
||||
function firstArg(args: string): string {
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const c = args[i];
|
||||
if (quote) {
|
||||
if (c === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (c === "'" || c === '"' || c === '`') {
|
||||
quote = c;
|
||||
continue;
|
||||
}
|
||||
if (c === '(' || c === '[' || c === '{') depth++;
|
||||
else if (c === ')' || c === ']' || c === '}') depth--;
|
||||
else if (c === ',' && depth === 0) return args.slice(0, i);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/** 路径参数是否"看起来像" .md / note / markdown */
|
||||
function looksLikeNoteBody(arg: string): boolean {
|
||||
if (/\.md\b/i.test(arg)) return true;
|
||||
if (/\b(md|note|notes|markdown)[A-Za-z]*\b/i.test(arg)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
interface Hit {
|
||||
file: string;
|
||||
line: number;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
function collectHits(): Hit[] {
|
||||
const hits: Hit[] = [];
|
||||
for (const file of walkTs(join(ROOT, 'src/main'))) {
|
||||
const rel = file.slice(ROOT.length + 1).replaceAll('\\', '/');
|
||||
if (WHITELIST.has(rel)) continue;
|
||||
const src = readFileSync(file, 'utf8');
|
||||
// 只认独立的 readFile( / readFileSync( —— readFileForReader( 不算
|
||||
const re = /(?<![A-Za-z0-9_$])readFile(?:Sync)?\s*\(/g;
|
||||
for (const m of src.matchAll(re)) {
|
||||
const openIdx = m.index! + m[0].lastIndexOf('(');
|
||||
const arg = firstArg(argsOf(src, openIdx));
|
||||
if (!looksLikeNoteBody(arg)) continue;
|
||||
const line = src.slice(0, m.index).split('\n').length;
|
||||
hits.push({ file: rel, line, snippet: m[0].trim() + arg.trim().slice(0, 60) });
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
describe('笔记 body 单一入口守卫(r203)', () => {
|
||||
it('src/main/** 没有绕开 body-cap 直接读 .md', () => {
|
||||
const hits = collectHits();
|
||||
const detail = hits.map((h) => ` ${h.file}:${h.line} → ${h.snippet}`).join('\n');
|
||||
expect(
|
||||
hits,
|
||||
`以下位置疑似直接读笔记 body(应改走 utils/body-cap.ts:readBodyWithCap):\n${detail}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('白名单里的两个文件都还在(守卫不能指向不存在的例外)', () => {
|
||||
for (const w of WHITELIST) {
|
||||
expect(() => statSync(join(ROOT, w)), `白名单文件不存在:${w}`).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('检测器本身能认出「.md 路径」与「无关路径」', () => {
|
||||
expect(looksLikeNoteBody("join(dir, 'note.md')")).toBe(true);
|
||||
expect(looksLikeNoteBody('notePath')).toBe(true);
|
||||
expect(looksLikeNoteBody('markdownFile')).toBe(true);
|
||||
expect(looksLikeNoteBody("join(def, 'settings.json')")).toBe(false);
|
||||
expect(looksLikeNoteBody('locationPath()')).toBe(false);
|
||||
});
|
||||
});
|
||||
121
tests/unit/main/lint/no-fixed-temp-dir.test.ts
Normal file
121
tests/unit/main/lint/no-fixed-temp-dir.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
// tests/unit/main/lint/no-fixed-temp-dir.test.ts
|
||||
//
|
||||
// 守「测试的临时目录必须每次跑测唯一」这条约定(r209)。
|
||||
//
|
||||
// ── 被守卫的缺陷(不是风格问题,是并发跑测时的假红)────────────────────
|
||||
// 约定:临时目录用 `fs.mkdtemp(join(tmpdir(), 'prefix-'))` —— 名字带
|
||||
// 随机后缀,每次跑测唯一。仓库里 34 处测试都这么写。
|
||||
//
|
||||
// 偏离:少数几个测试文件用**固定**目录名 + 递归删:
|
||||
// const TMP = join(tmpdir(), 'rkms-r205');
|
||||
// beforeEach(async () => { await fs.rm(TMP, { recursive: true, force: true }); … })
|
||||
//
|
||||
// 后果:两次**并发**的跑测(CI 分片 / watch 模式重跑 / 两个 checkout 同时
|
||||
// 跑 / 一条命令跑全量 + 另一条命令单跑某个文件)会用**同一个**目录,
|
||||
// 于是一个进程的 `fs.rm -rf` 把另一个进程**正在用**的目录树删掉 ⇒
|
||||
// 一片 ENOENT / ENOTEMPTY 假红,而两个测试**单跑都是绿的**。
|
||||
//
|
||||
// 实测(2026-10-20,真机):一条命令跑全量(81 文件)、另一条并行单跑
|
||||
// rkms-r205 那个文件 ⇒ 该文件 4 例红:
|
||||
// ENOENT: chmod '…\rkms-r205\default\notes' (fs.cp 刚建好的目录没了)
|
||||
// ENOENT: scandir '…\rkms-r205\current'
|
||||
// ENOENT: mkdir '…\rkms-r205\current'
|
||||
// ENOTEMPTY: rmdir '…\rkms-r205\default'
|
||||
// 两条命令**各自单跑**都 7/7 绿 ⇒ 竞争,不是被测代码的缺陷。
|
||||
// 这类假红最贵:它看起来像「代码被改坏了」,会让人去改**没错**的产品代码。
|
||||
//
|
||||
// ── 判定方式 ───────────────────────────────────────────────────────
|
||||
// 静态扫 tests/**:凡是 `join(tmpdir(), '<字面量>')` 形式的**固定**路径,
|
||||
// 必须紧跟在 `mkdtemp(` 之后(即由 mkdtemp 生成唯一名)。
|
||||
// - `mkdtemp(join(tmpdir(), 'rkms-x-'))` → 放行
|
||||
// - `join(tmpdir(), 'rkms-x')` → 违规(固定名)
|
||||
//
|
||||
// 覆盖边界(诚实声明):静态判定只认**字面量**路径。写成
|
||||
// `join(tmpdir(), \`rkms-${Date.now()}\`)` 的动态名**放行**(它每次都不同),
|
||||
// 但那种写法不是原子唯一的 —— 两个进程在同一毫秒起跑仍可能撞名,
|
||||
// 所以约定仍然是 mkdtemp。
|
||||
import { readdirSync, readFileSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const TESTS_DIR = join(ROOT, 'tests');
|
||||
|
||||
/**
|
||||
* 本守卫自己 exempt:它的源码里必然出现「固定 tmpdir 路径」的字面写法
|
||||
* (注释与正则),扫到自己会误报。
|
||||
*/
|
||||
const SELF = 'tests/unit/main/lint/no-fixed-temp-dir.test.ts';
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) out.push(...walk(full));
|
||||
else if (/\.tsx?$/.test(entry)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `join(tmpdir(), '…')` / `join(os.tmpdir(), '…')` 里的字面量参数 */
|
||||
const FIXED_TMP_PATH_RE = /join\(\s*(?:os\.)?tmpdir\(\)\s*,\s*(['"`])([^'"`]*)\1\s*\)/g;
|
||||
|
||||
interface Violation {
|
||||
file: string;
|
||||
line: number;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
function scanFile(abs: string): Violation[] {
|
||||
const src = readFileSync(abs, 'utf-8');
|
||||
const rel = abs.slice(ROOT.length + 1).split('\\').join('/');
|
||||
if (rel === SELF) return [];
|
||||
|
||||
const out: Violation[] = [];
|
||||
for (const m of src.matchAll(FIXED_TMP_PATH_RE)) {
|
||||
const literal = m[2] ?? '';
|
||||
// 动态名(带插值)每次都不同 —— 放行(但约定上仍该用 mkdtemp)
|
||||
if (literal.includes('${')) continue;
|
||||
const before = src.slice(0, m.index);
|
||||
// 紧跟在 mkdtemp( 之后 ⇒ 由 mkdtemp 生成唯一名 ⇒ 放行
|
||||
if (/mkdtemp\(\s*$/.test(before)) continue;
|
||||
const line = src.slice(0, m.index).split('\n').length;
|
||||
out.push({ file: rel, line, snippet: m[0].replace(/\s+/g, ' ') });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('临时目录必须每次跑测唯一(静态守卫)', () => {
|
||||
it('tests/ 下不得出现固定的 tmpdir 路径(必须是 mkdtemp)', () => {
|
||||
const violations = walk(TESTS_DIR).flatMap(scanFile);
|
||||
|
||||
expect(
|
||||
violations,
|
||||
violations.length > 0
|
||||
? `固定临时目录名会让并发的两轮跑测互删对方的目录树(实测假红):\n` +
|
||||
violations.map((v) => ` ${v.file}:${v.line} ${v.snippet}`).join('\n') +
|
||||
`\n改法:const TMP = await fs.mkdtemp(join(tmpdir(), 'rkms-xxx-'));`
|
||||
: '',
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('守卫本身是有效的(拿一个已知的违规写法做对照)', () => {
|
||||
const fake = [
|
||||
"const TMP = join(tmpdir(), 'rkms-fake');",
|
||||
"const OK = await fs.mkdtemp(join(tmpdir(), 'rkms-fake-'));",
|
||||
"const ALSO_OK = join(tmpdir(), `rkms-${Date.now()}`);",
|
||||
].join('\n');
|
||||
const src = fake;
|
||||
const hits: string[] = [];
|
||||
for (const m of src.matchAll(FIXED_TMP_PATH_RE)) {
|
||||
const literal = m[2] ?? '';
|
||||
if (literal.includes('${')) continue;
|
||||
const before = src.slice(0, m.index);
|
||||
if (/mkdtemp\(\s*$/.test(before)) continue;
|
||||
hits.push(m[0]);
|
||||
}
|
||||
// 第一条(固定名)被抓到,后两条(mkdtemp / 动态名)放行
|
||||
expect(hits).toHaveLength(1);
|
||||
expect(hits[0]).toContain('rkms-fake');
|
||||
});
|
||||
});
|
||||
169
tests/unit/main/lint/stale-test-reference.test.ts
Normal file
169
tests/unit/main/lint/stale-test-reference.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
// tests/unit/main/lint/stale-test-reference.test.ts
|
||||
//
|
||||
// 守卫(r180):源码注释里引用的**测试文件路径必须真实存在**,否则要么
|
||||
// 明确标注为「守卫缺失」。
|
||||
//
|
||||
// 为什么需要这条:2026-10-19 的 docs 大下线把整棵 tests/ 目录删掉了
|
||||
// (CHANGELOG 记录曾达 2501 例),但源码注释里 45 处「行为锁:xxx.test.ts」
|
||||
// 的指针还指着已经不存在的文件。后果不是编译错误,而是**更坏的**:
|
||||
// - 后来的人 grep 那个测试名 → 找不到 → 以为「这行为没被测过」→
|
||||
// 重写一遍(白干),或者
|
||||
// - 以为「有测试兜着」→ 不写测试就改行为 → 回归无人拦。
|
||||
// 注释里的测试指针是**契约的一部分**,指针死了必须显式说「死了」。
|
||||
//
|
||||
// 约定:引用一个已不存在的测试文件时,同一行必须带 🔒 标记。
|
||||
// 现状:45 处已按约定标注(逐条改成 `路径 🔒(守卫缺失)`)。
|
||||
// 这条测试只拦**未标注**的死指针 —— 标注过的允许存在(它们是历史记录,
|
||||
// 补回测试时把 🔒 去掉即可,这条测试会继续守着)。
|
||||
//
|
||||
// audit-r203 补上**反向**方向:🔒 标记会**过期** —— 守卫测试补齐后,
|
||||
// 源码里的 `路径 🔒(守卫缺失)` 就不再成立,读者仍会以为「这块没测试兜着」
|
||||
// 而重复造轮子(或反过来,以为「🔒 = 有问题」而不敢动)。约定是
|
||||
// 「补回测试就把 🔒 去掉」,那就让机器来查有没有人忘了去掉。
|
||||
import { readdirSync, readFileSync, statSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const MARKER = '🔒';
|
||||
|
||||
// 只在源码目录里扫(测试文件之间互相引用少见,且会让本文件自指)。
|
||||
const SCAN_DIRS = ['src'];
|
||||
|
||||
function walkTs(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) out.push(...walkTs(full));
|
||||
else if (/\.tsx?$/.test(entry) && !entry.endsWith('.d.ts')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 匹配注释 / 字符串里出现的「测试文件路径」字面量。
|
||||
// 写成拼接形式,避免本文件自身被自己的正则命中。
|
||||
const TEST_PATH_RE = new RegExp('tests[/\\\\][A-Za-z0-9_\\\\/-]+?\\.test\\.tsx?', 'g');
|
||||
|
||||
interface Violation {
|
||||
file: string;
|
||||
line: number;
|
||||
target: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** 取出该行引用的全部测试路径(去重) */
|
||||
function targetsIn(text: string): string[] {
|
||||
TEST_PATH_RE.lastIndex = 0;
|
||||
const out: string[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = TEST_PATH_RE.exec(text)) !== null) {
|
||||
const t = m[0].replaceAll('\\', '/');
|
||||
if (!out.includes(t)) out.push(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectViolations(): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
for (const dir of SCAN_DIRS) {
|
||||
for (const file of walkTs(join(ROOT, dir))) {
|
||||
const lines = readFileSync(file, 'utf8').split('\n');
|
||||
lines.forEach((text, i) => {
|
||||
TEST_PATH_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = TEST_PATH_RE.exec(text)) !== null) {
|
||||
const target = m[0].replaceAll('\\', '/');
|
||||
if (existsSync(join(ROOT, target))) continue;
|
||||
if (text.includes(MARKER)) continue; // 已显式标注「守卫缺失」
|
||||
violations.push({
|
||||
file: file.slice(ROOT.length + 1),
|
||||
line: i + 1,
|
||||
target,
|
||||
text: text.trim().slice(0, 120),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集**过期**的 🔒 标记:某行带 🔒,且它引用的测试文件**全部**都已存在
|
||||
* ⇒ 这条 🔒 已经过期,应当去掉。
|
||||
*
|
||||
* 判据用「全部存在」而不是「任一存在」:一行里可能同时引用多个测试
|
||||
* (有的存在、有的仍缺),此时 🔒 仍然必要(还指着缺失的那个)。
|
||||
*/
|
||||
function collectStaleMarkers(): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
for (const dir of SCAN_DIRS) {
|
||||
for (const file of walkTs(join(ROOT, dir))) {
|
||||
const lines = readFileSync(file, 'utf8').split('\n');
|
||||
lines.forEach((text, i) => {
|
||||
if (!text.includes(MARKER)) return;
|
||||
const targets = targetsIn(text);
|
||||
if (targets.length === 0) return;
|
||||
if (!targets.every((t) => existsSync(join(ROOT, t)))) return;
|
||||
violations.push({
|
||||
file: file.slice(ROOT.length + 1),
|
||||
line: i + 1,
|
||||
target: targets.join(', '),
|
||||
text: text.trim().slice(0, 120),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
describe('源码注释里的测试指针(r180)', () => {
|
||||
it('引用的测试文件必须存在,或已在同一行标注 🔒 守卫缺失', () => {
|
||||
const violations = collectViolations();
|
||||
const detail = violations
|
||||
.map((v) => ` ${v.file}:${v.line} → ${v.target}\n ${v.text}`)
|
||||
.join('\n');
|
||||
expect(
|
||||
violations,
|
||||
`以下注释引用了不存在的测试文件,且没有 🔒(守卫缺失)标注:\n${detail}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('扫描器本身能认出「已标注」与「未标注」两种状态', () => {
|
||||
// 探针:确认正则与标注约定真的生效(否则上面那条可能因为正则失配而空过)
|
||||
const sample = [
|
||||
'// 行为锁:tests/unit/definitely/absent.test.ts',
|
||||
'// 行为锁:tests/unit/definitely/absent.test.ts 🔒(守卫缺失)',
|
||||
];
|
||||
const hits = sample.filter((line) => {
|
||||
TEST_PATH_RE.lastIndex = 0;
|
||||
const m = TEST_PATH_RE.exec(line);
|
||||
return m !== null && !line.includes(MARKER);
|
||||
});
|
||||
expect(hits).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('🔒(守卫缺失)标记的时效性(r203)', () => {
|
||||
it('守卫测试已补齐的行,不许还留着 🔒 标记', () => {
|
||||
const stale = collectStaleMarkers();
|
||||
const detail = stale.map((v) => ` ${v.file}:${v.line} → ${v.target}\n ${v.text}`).join('\n');
|
||||
expect(
|
||||
stale,
|
||||
`以下行的测试已存在,🔒(守卫缺失)标记已过期,请去掉:\n${detail}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('扫描器能区分「过期标记」与「仍然有效的标记」', () => {
|
||||
// 探针:一个引用真实存在文件的 🔒 行 → 过期;一个引用不存在文件的 🔒
|
||||
// 行 → 仍然有效。两条判据都跑一遍,防止上面那条因为逻辑写反而空过。
|
||||
const realFile = 'tests/unit/main/lint/stale-test-reference.test.ts';
|
||||
const absentFile = 'tests/unit/definitely/absent.test.ts';
|
||||
const realTargets = targetsIn(`// ${realFile} ${MARKER}`);
|
||||
const absentTargets = targetsIn(`// ${absentFile} ${MARKER}`);
|
||||
expect(realTargets).toEqual([realFile]);
|
||||
expect(existsSync(join(ROOT, realTargets[0]))).toBe(true);
|
||||
expect(existsSync(join(ROOT, absentTargets[0]))).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user