Files
Literature-Management/tests/unit/layering.test.ts
2026-10-21 14:42:59 +08:00

318 lines
12 KiB
TypeScript

// tests/unit/layering.test.ts
//
// 守卫:进程分层 import 边界。renderer↔main 之间只能通过 preload(window.api)
// 桥,不能直接 import 对方代码。
//
// 规则:
// - src/main/**/*.{ts,tsx} 禁止 import renderer 代码
// - src/renderer/src/**/*.{ts,tsx} 禁止 import 主进程模块(走 window.api 桥)
// - src/preload/**/*.{ts,tsx} 允许 import main 的**类型**(IPC 类型 contract)
// 禁止 import renderer(否则把 renderer 逻辑拉进 main)
//
// ── audit-r254:守卫自身从「正则匹配源码文本」升级为「解析 import 并解析真实路径」──
//
// 旧实现有三处**假绿**(守卫绿着,却拦不住它声称要拦的东西)。以下每条
// 都做过**实证**(把违规探针塞进 src/,跑旧守卫看它是否转红):
//
// ① 跨语句吞并(P0)—— preload 那条规则用
// /import\s+(?:type\s+)?[\s\S]*?from\s*['"](?:@main\/|\.\.\/main\/…)/g
// `[\s\S]*?` 会把**两条相邻 import** 合成一条语句。于是
// import type { A } from '@shared/types'; ← 纯类型
// import { errMsg } from '../main/utils/error'; ← 运行时值(违规)
// 被并成一条,`isTypeOnlyImport` 看到行首的 `import type` ⇒ 判「纯类型」
// ⇒ **放行真正的违规**。
// 实证:旧守卫对这段 preload 代码 **绿**(违规在)。
//
// ② 别名漏网(P1)—— tsconfig paths 里 `@/*` 也指向 src/renderer/src,
// 而旧规则只认 `@renderer/` 与相对路径 ⇒ main 进程里写
// import { x } from '@/components/foo';
// **完全不被拦**。
// 实证:旧守卫对这段 main 代码 **绿**(违规在)。
//
// ③ 动态 import 漏网(P1)—— 旧规则只匹配 `from '…'` 与 `require('…')`,
// 不含 `import('…')` ⇒ renderer 里写
// const m = await import('@main/utils/error');
// **完全不被拦**。
// 实证:旧守卫对这段 renderer 代码 **绿**(违规在)。
//
// (另注:旧规则靠手写层级正则,`../../main` 是**子串**匹配 —— 幸运的是
// 它也能命中 `../../../main`(实测),所以相对路径这一维并没漏;但同样
// 的子串语义会误命中 `../../mainland` 这类目录名。改用真实路径解析后,
// 任意层级都对,且不再靠手写清单。)
//
// 新实现:先把源码里的注释剥掉(避免注释里的 import 字样误报/漏报),
// 再逐条**解析** import 语句拿到模块说明符,最后把说明符**解析成真实
// 绝对路径**并判定它落在哪一层 —— 别名(@main/ @renderer/ @shared/ @/)与
// 相对路径(任意层级)一律按解析结果判定,不再靠手写层级清单。
//
// 允许破例的清单(测试用例、源头已知安全)用 ALLOWED_REASON 注释说明。
import { readdirSync, readFileSync, statSync } from 'fs';
import { dirname, join, relative, resolve, sep } from 'path';
import { describe, expect, it } from 'vitest';
const ROOT = process.cwd();
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;
}
interface Violation {
file: string;
line: number;
text: string;
}
type Layer = 'main' | 'renderer' | 'preload' | 'shared';
/**
* 剥掉注释,只留「代码」文本(逐行,行号不变)。
*
* 为什么必须剥:守卫扫的是**源码文本**,注释里写一句
* // 曾经写过:import { x } from '../main/utils/y';
* 就会把注释当成真 import(误报);反过来,注释里那句**真曾发生过**的
* 违规也不会被记录(漏报的「历史痕迹」不算问题,但不能反过来把真代码
* 也放过)。剥掉注释后,匹配到的就一定是真语句。
*
* 说明:模板字符串跨行时,本扫描器按「行内闭合」处理 —— 极端情况下会把
* 模板里的一行截断(只会造成漏报,不会误报)。
*/
function codeLines(src: string): string[] {
const out: string[] = [];
let inBlock = false;
for (const raw of src.split('\n')) {
let outLine = '';
let i = 0;
let quote: string | null = null;
while (i < raw.length) {
const c = raw[i]!;
const n = raw[i + 1];
if (inBlock) {
if (c === '*' && n === '/') {
inBlock = false;
i += 2;
continue;
}
i++;
continue;
}
if (quote) {
if (c === '\\') {
outLine += c + (n ?? '');
i += 2;
continue;
}
if (c === quote) quote = null;
outLine += c;
i++;
continue;
}
if (c === '/' && n === '*') {
inBlock = true;
i += 2;
continue;
}
if (c === '/' && n === '/') break; // 行注释 → 本行剩余丢弃
if (c === '"' || c === "'" || c === '`') {
quote = c;
outLine += c;
i++;
continue;
}
outLine += c;
i++;
}
out.push(outLine);
}
return out;
}
interface ImportHit {
/** 模块说明符(原样,如 '../main/utils/error') */
spec: string;
/** 命中的整条语句(归一化空白,便于报错展示) */
stmt: string;
/** 是否纯类型 import(不引入运行时值) */
typeOnly: boolean;
/** 1 基行号 */
line: number;
}
// import 语句:`import type X from 'y'` / `import { a, type b } from 'y'` /
// `import 'y'`(副作用) / `export ... from 'y'`(再导出)。
//
// 关键:`[^;'"]*?` 而不是 `[\s\S]*?` —— 前者**不允许**跨过引号与分号,
// 于是相邻两条 import **不会**被合成一条(旧实现的 P0-1)。
const IMPORT_FROM_RE = /\bimport\s+(type\s+)?([^;'"]*?)from\s*['"]([^'"]+)['"]/g;
const EXPORT_FROM_RE = /\bexport\s+([^;'"]*?)from\s*['"]([^'"]+)['"]/g;
const BARE_IMPORT_RE = /\bimport\s*['"]([^'"]+)['"]/g;
const REQUIRE_RE = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
const DYNAMIC_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
/** 判断一条 import 语句是否**纯类型**(不引入运行时值)。 */
function isTypeOnlyImport(stmt: string): boolean {
const t = stmt.trim();
if (/^import\s+type\b/.test(t)) return true;
const m = /import\s*\{([\s\S]*?)\}/.exec(t);
if (!m) return false; // default / namespace import ⇒ 运行时值
const specs = m[1]!
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return specs.length > 0 && specs.every((s) => s.startsWith('type '));
}
/** 收集一个文件里的所有 import / re-export / require / 动态 import。 */
function collectImports(src: string): ImportHit[] {
const lines = codeLines(src);
const hits: ImportHit[] = [];
const push = (spec: string, stmt: string, line: number): void => {
hits.push({ spec, stmt: stmt.replace(/\s+/g, ' ').trim(), typeOnly: isTypeOnlyImport(stmt), line });
};
lines.forEach((text, idx) => {
for (const m of text.matchAll(IMPORT_FROM_RE)) {
push(m[3]!, m[0], idx + 1);
}
for (const m of text.matchAll(EXPORT_FROM_RE)) {
push(m[2]!, m[0], idx + 1);
}
for (const m of text.matchAll(BARE_IMPORT_RE)) {
push(m[1]!, m[0], idx + 1);
}
for (const m of text.matchAll(REQUIRE_RE)) {
push(m[1]!, m[0], idx + 1);
}
for (const m of text.matchAll(DYNAMIC_RE)) {
push(m[1]!, m[0], idx + 1);
}
});
return hits;
}
/**
* 把模块说明符解析成「它落在哪一层」。
*
* 别名(tsconfig paths):
* @shared/* → src/shared (共享层,任何层都可引)
* @main/* → src/main
* @renderer/*→ src/renderer/src
* @/* → src/renderer/src
* 相对路径:按**导入文件所在目录**解析成绝对路径 —— 任意层级都对,
* 不再靠手写 `../../main` 这类层级清单(旧实现的 P0-2)。
* 裸包名:返回 null(第三方依赖,不在分层管辖内)。
*/
function layerOfSpecifier(spec: string, importerRel: string): Layer | null {
if (spec.startsWith('@shared/')) return 'shared';
if (spec.startsWith('@main/')) return 'main';
if (spec.startsWith('@renderer/') || spec.startsWith('@/')) return 'renderer';
if (!spec.startsWith('.')) return null; // 第三方包
const abs = resolve(ROOT, dirname(importerRel), spec);
const rel = relative(ROOT, abs).split(sep).join('/');
if (rel.startsWith('src/main/')) return 'main';
if (rel.startsWith('src/renderer/src/')) return 'renderer';
if (rel.startsWith('src/preload/')) return 'preload';
if (rel.startsWith('src/shared/')) return 'shared';
return null;
}
function detectLayer(relPath: string): Layer | null {
const rel = relPath.split(sep).join('/');
if (rel.startsWith('src/main/')) return 'main';
if (rel.startsWith('src/renderer/src/')) return 'renderer';
if (rel.startsWith('src/preload/')) return 'preload';
return null;
}
interface Rule {
layer: Exclude<Layer, 'shared'>;
/** 目标层:命中即越层 */
forbiddenTarget: Exclude<Layer, 'shared'>;
reason: string;
}
const RULES: Rule[] = [
{
layer: 'main',
forbiddenTarget: 'renderer',
reason: 'main 进程不应 import renderer 代码(IPC 是唯一通道)。',
},
{
layer: 'renderer',
forbiddenTarget: 'main',
reason: 'renderer 代码不应 import 主进程模块(走 window.api 桥)。',
},
{
layer: 'preload',
forbiddenTarget: 'renderer',
reason: 'preload 是 bridge,不应 import renderer(否则把 renderer 逻辑拉进 main)。',
},
];
describe('进程分层 import 边界', () => {
const sourceFiles = walkTs(join(ROOT, 'src'));
for (const rule of RULES) {
it(`${rule.layer}: ${rule.reason}`, () => {
const violations: Violation[] = [];
for (const file of sourceFiles) {
const rel = relative(ROOT, file);
if (detectLayer(rel) !== rule.layer) continue;
const src = readFileSync(file, 'utf8');
for (const hit of collectImports(src)) {
if (layerOfSpecifier(hit.spec, rel) !== rule.forbiddenTarget) continue;
violations.push({ file: rel, line: hit.line, text: hit.stmt });
}
}
if (violations.length > 0) {
// eslint-disable-next-line no-console
console.error('layering violations:', violations);
}
expect(violations).toEqual([]);
});
}
/**
* audit-r229:preload 只能 import main 的**类型**(IPC 类型 contract),
* 不得 import main 的**运行时值**。
*
* 事故形态:src/preload/index.ts 曾写
* `import { errMsg } from '../main/utils/error';`
* —— 这是**值** import(不是 import type),把 main 的模块图拖进 preload,
* 进而拖进 renderer 进程的启动路径。ARCHITECTURE「preload 只做桥、不含业务」
* 与本文件头注「允许 import @main/*(IPC 类型 contract)」都只授权**类型**。
* 同一文件里 r140 刚把 MAX_TOAST_MESSAGE_CHARS 从 main 搬到
* @shared/limits/ipc「让 preload 跨进程 import main 间接」—— 同一个文件里
* 一半搬走一半留下,自相矛盾。
*
* 修法(r229):errMsg 本体下沉到 @shared/display-string,preload 直连 shared。
* 本规则把「preload 不得运行时引 main」钉住,防止下一个 helper 又走老路。
*/
it('preload: 不得 import main 的运行时值(只允许类型)', () => {
const violations: Violation[] = [];
for (const file of sourceFiles) {
const rel = relative(ROOT, file);
if (detectLayer(rel) !== 'preload') continue;
const src = readFileSync(file, 'utf8');
for (const hit of collectImports(src)) {
if (layerOfSpecifier(hit.spec, rel) !== 'main') continue;
if (hit.typeOnly) continue;
violations.push({ file: rel, line: hit.line, text: hit.stmt });
}
}
if (violations.length > 0) {
// eslint-disable-next-line no-console
console.error('preload→main runtime import violations:', violations);
}
expect(violations).toEqual([]);
});
});