Files
dsh_shenxian/scripts/check-layering.mjs
T
admin e6207aa691
build / build-and-scan (push) Waiting to run
chore(仓库对齐): 文档库结构治理 + IM/插件线落地
文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。

IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。

插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。

仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
2026-09-24 07:25:16 +08:00

139 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)