S0(本机):新增 src/net/reachability.ts(Reachability 描述 + agentBaseUrlOf 唯一取值入口)、src/net/rendezvous.ts、docs/architecture.md(四层划分 + 依赖方向 + R1-R4 判据)、scripts/check-layering.mjs + layering-baseline.json、test/reachability.test.mjs;src/supervisor/remote-spawner.ts 与 src/web/server.ts 改为统一走 agentBaseUrlOf(),agentUrl 降级为可选旧字段(向后兼容)。 S1(本机 + 106):新增 DSHS_RENDEZVOUS_URL 出 env(src/config.ts 解析 clusterRendezvousUrl,优先级 overrides > 新变量 > DSHS_TUNNEL_TARGET 兜底 > 空),src/worker/tunnel.ts 新增 normalizeTunnelTarget()、src/worker/agent.ts 改读新配置 ⇒ 双路径并存、可零代码回滚(删掉新 env 即走旧路径)。106 已上线,健康检查 tunnel.ready=true。 package.json 增加 check:layering 脚本并接入 verify;README 登记分层文档。 验收:npm run build 通过;npm test 44/44;check:layering 无新增违规(基线 5 条)。
129 lines
6.0 KiB
JavaScript
129 lines
6.0 KiB
JavaScript
#!/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)\//.test(p)) return 3
|
||
if (/^src\/(config|crypto|isolation|index)\.ts$/.test(p)) return 4
|
||
return null // 未归类 ⇒ 新增目录必须先想清"它属于哪一层"
|
||
}
|
||
const NAME = { 1: '①入口', 2: '②领域', 3: '③能力', 4: '④基础' }
|
||
|
||
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)
|