318 lines
12 KiB
TypeScript
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([]);
|
|
});
|
|
});
|