#!/usr/bin/env node /** * 分层依赖方向检查 —— `docs/architecture.md` 的 R1–R4 **可执行判据**。 * * ## 为什么必须有它 * 规则**只有文字**时靠自觉 ⇒ 扩张期一定会走丢。本脚本把四条规则变成一次静态扫描, * "分层有没有走形"不再靠人判断(`npm run check:layering`)。 * * ## 判据(层号 ① 最上、④ 最下;**目标层号 < 源层号 ⇒ 违规**) * | 规则 | 内容 | * |---|---| * | R1 | 上层可依赖下层,**下层不得 import 上层** | * | R3 | `net/*` 属能力层,**不得 import `web/*`** | * | R4 | `config/crypto/isolation/index` **零业务依赖**(任何子目录 import 都算违规) | * * ## 基线(ratchet,不是豁免) * 现存违规登记在 `scripts/layering-baseline.json`(**键 = 源→目标 的边**,不含行号 ⇒ 抗行号漂移)。 * · **新增**违规 ⇒ 退出码 **1**(规则从今天起生效) * · 基线里**已消失**的项 ⇒ 打印提醒,可 `--update-baseline` 收紧 * 这样"立规矩"不会因为存量债而把 `npm run verify` 卡红。 * * 用法:`node scripts/check-layering.mjs [--update-baseline] [--json]` */ import { readFileSync, readdirSync, statSync, writeFileSync, existsSync } from 'node:fs' import { join, dirname, relative, sep } from 'node:path' const ROOT = process.cwd() const SRC = join(ROOT, 'src') const BASELINE = join(ROOT, 'scripts', 'layering-baseline.json') const args = new Set(process.argv.slice(2)) const posixify = (p) => p.split(sep).join('/') /** 层归属:**只按目录判**,不做语义推断(判据要能一眼看懂)。 */ function layerOf(file) { const p = posixify(file) if (p === 'src/cli.ts' || p.startsWith('src/web/')) return 1 if (p.startsWith('src/domain/')) return 2 if (/^src\/(supervisor|db|fs|worker|net|nginx|im)\//.test(p)) return 3 if (/^src\/(config|crypto|isolation|index)\.ts$/.test(p)) return 4 return null // 未归类 ⇒ 新增目录必须先想清"它属于哪一层" } const NAME = { 1: '①入口', 2: '②领域', 3: '③能力', 4: '④基础' } // 定层留痕(2026-09-22 补 · `src/im` 曾长期"未归类" ⇒ 分层检查对它静默不判) // ───────────────────────────────────────────────────────────────────────── // `src/im/**`(含 `src/im/sdk/**`)定 **③能力层**,依据 = 实测依赖方向: // · 只 import `../db/{connection,schema}.js` ⇒ ③→③ **同层**(R2 允许) // · 零 `../web/` `../domain/` 引用 ⇒ **无回指**,符合 R1 // · 反向扇入 3 处(`web/server.ts` 挂载 · `supervisor/{orchestrator,remote-spawner}` // 的 `import type`)⇒ 全是**上层依赖下层**,符合 R1 方向 // · 反向扇入 `web/server.ts` 是层①,`supervisor/*` 与自身同为层③ ⇒ 无越层 // ⚠️ 若将来 `src/im` 需要 import `../web/**` ⇒ 定层即错,必须改为"由层①注入依赖"。 function walk(dir) { const out = [] for (const e of readdirSync(dir)) { const full = join(dir, e) if (statSync(full).isDirectory()) out.push(...walk(full)) else if (e.endsWith('.ts')) out.push(full) } return out } /** 所有 `from '<相对>'` / `from "<相对>"`(含 `export … from`)。 */ function importsOf(text) { const out = [] text.split('\n').forEach((line, i) => { const re = /\bfrom\s+['"]([^'"]+)['"]/g let m while ((m = re.exec(line)) !== null) if (m[1].startsWith('.')) out.push({ spec: m[1], line: i + 1 }) }) return out } // ⚠️ 统一成**相对仓根**的 posix 路径 —— 层判据是按 `src/...` 写的, // 直接喂绝对路径会让每个文件都落进"未归类"(首版就踩了这个坑,静默全绿)。 const files = walk(SRC).map((f) => posixify(relative(ROOT, f))) const counts = { 1: 0, 2: 0, 3: 0, 4: 0 } const unclassified = [] const found = [] // { edge, detail } for (const file of files) { const srcLayer = layerOf(file) if (srcLayer === null) { unclassified.push(file) continue } counts[srcLayer] += 1 for (const { spec, line } of importsOf(readFileSync(join(ROOT, file), 'utf8'))) { const target = posixify(join(dirname(file), spec)).replace(/\.js$/, '.ts') const tgtLayer = layerOf(target) if (tgtLayer === null) continue const edge = `${file} → ${target}` const detail = `${file}:${line} → ${target}` if (tgtLayer < srcLayer) { const tag = srcLayer === 3 && file.includes('/net/') && target.startsWith('src/web/') ? 'R3' : 'R1' found.push({ edge, detail: `${tag} ${detail} [${NAME[srcLayer]} → ${NAME[tgtLayer]}]` }) } else if (srcLayer === 4) { found.push({ edge, detail: `R4 ${detail} [④基础层必须零业务依赖]` }) } } } // ── 基线比对 ──────────────────────────────────────────────────────────────── const baseline = existsSync(BASELINE) ? new Set(JSON.parse(readFileSync(BASELINE, 'utf8')).edges ?? []) : null const isNew = (v) => baseline === null || !baseline.has(v.edge) const added = found.filter(isNew) const known = found.filter((v) => !isNew(v)) const fixed = baseline === null ? [] : [...baseline].filter((e) => !found.some((v) => v.edge === e)) if (args.has('--update-baseline')) { writeFileSync(BASELINE, `${JSON.stringify({ note: 'R1–R4 存量违规基线(ratchet):只许减、不许增;新增违规会让 check:layering 失败', edges: [...new Set(found.map((v) => v.edge))].sort() }, null, 2)}\n`) console.log(`✓ 基线已更新:${found.length} 条边 → ${posixify(BASELINE)}`) process.exit(0) } if (args.has('--json')) { console.log(JSON.stringify({ counts, unclassified, added, known, fixed }, null, 2)) process.exit(added.length > 0 ? 1 : 0) } console.log('=== 分层依赖检查(docs/architecture.md R1–R4)===') console.log(`扫描:${files.length} 个 .ts|归属 ①${counts[1]} ②${counts[2]} ③${counts[3]} ④${counts[4]}|未归类 ${unclassified.length}`) if (unclassified.length > 0) console.log(`⚠️ 未归类(新增目录必须先定层):${unclassified.slice(0, 5).join(', ')}${unclassified.length > 5 ? ' …' : ''}`) console.log(`现存违规 ${found.length} 条(其中基线内 ${known.length} 条)`) if (added.length > 0) { console.log(`\n🔴 新增违规 ${added.length} 条(⛔ 规则已生效,不得引入):`) for (const v of added) console.log(` ${v.detail}`) } if (fixed.length > 0) { console.log(`\n🟢 基线内已消除 ${fixed.length} 条(可 --update-baseline 收紧):`) for (const e of fixed.slice(0, 8)) console.log(` ${e}`) } if (added.length === 0) { console.log(baseline === null ? '\n⚠️ 尚无基线文件:首次请先跑 --update-baseline 建立(否则任何存量违规都会被算作"新增")' : '\n✅ 无新增违规') } process.exit(added.length > 0 ? 1 : 0)