2026-09-16 15:02:52 +08:00
|
|
|
|
#!/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
|
2026-09-24 07:25:16 +08:00
|
|
|
|
if (/^src\/(supervisor|db|fs|worker|net|nginx|im)\//.test(p)) return 3
|
2026-09-16 15:02:52 +08:00
|
|
|
|
if (/^src\/(config|crypto|isolation|index)\.ts$/.test(p)) return 4
|
|
|
|
|
|
return null // 未归类 ⇒ 新增目录必须先想清"它属于哪一层"
|
|
|
|
|
|
}
|
|
|
|
|
|
const NAME = { 1: '①入口', 2: '②领域', 3: '③能力', 4: '④基础' }
|
|
|
|
|
|
|
2026-09-24 07:25:16 +08:00
|
|
|
|
// 定层留痕(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/**` ⇒ 定层即错,必须改为"由层①注入依赖"。
|
|
|
|
|
|
|
2026-09-16 15:02:52 +08:00
|
|
|
|
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)
|