#!/usr/bin/env node /** * 依赖方向检查(任务 A1.2)。 * * 拦三类反向依赖: * 1. 包之间越界——core 引用具体 Provider、业务包直连 Provider、客户端引用服务端 * 2. 相对路径逃出包边界 * 3. 引擎包被 server/agent 之外的模块引用(ADR-0006) * * 用法:node scripts/check-dependency-direction.mjs [仓库根目录] * 退出码 0 = 无违规,1 = 有违规。 */ import { existsSync } from 'node:fs'; import { readFile, readdir, stat } from 'node:fs/promises'; import { dirname, join, relative, resolve, sep } from 'node:path'; import process from 'node:process'; import { pathToFileURL } from 'node:url'; /** 包 → 允许依赖的包。新增包必须在这里登记,否则视为违规。 */ export const PACKAGE_RULES = { '@aps/contracts': { allow: [] }, '@aps/provider-sdk': { allow: ['@aps/contracts'] }, '@aps/server': { allow: ['@aps/contracts', '@aps/provider-sdk'] }, '@aps/client-app': { allow: ['@aps/contracts'] }, '@aps/desktop': { allow: ['@aps/contracts', '@aps/client-app'] }, '@aps/provider-aps': { allow: ['@aps/contracts', '@aps/provider-sdk'] }, }; /** 引擎包:只有 server/agent 允许引用(ADR-0006)。 */ export const ENGINE_PACKAGES = ['@earendil-works/pi-agent-core', '@earendil-works/pi-ai']; const ENGINE_ALLOWED_PREFIX = 'server/agent/'; const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs']); const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'coverage', '__pycache__']); async function workspacePatterns(root) { const file = join(root, 'pnpm-workspace.yaml'); if (!existsSync(file)) throw new Error(`未找到 ${file}`); const text = await readFile(file, 'utf8'); const patterns = []; for (const line of text.split(/\r?\n/)) { const trimmed = line.trim(); if (trimmed.startsWith('- ')) patterns.push(trimmed.slice(2).trim().replace(/^['"]|['"]$/g, '')); } return patterns; } export async function workspacePackages(root) { const dirs = []; for (const pattern of await workspacePatterns(root)) { if (pattern.includes('*')) { const base = pattern.replace(/\/\*+$/, ''); const entries = await readdir(join(root, base), { withFileTypes: true }).catch(() => []); for (const entry of entries) if (entry.isDirectory()) dirs.push(`${base}/${entry.name}`); } else { dirs.push(pattern); } } return [...new Set(dirs)]; } async function collectSources(dir, nestedRoots, acc = []) { let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return acc; } for (const entry of entries) { const full = join(dir, entry.name); if (entry.isDirectory()) { if (SKIP_DIRS.has(entry.name)) continue; if (nestedRoots.has(full)) continue; // 嵌套包由它自己那轮检查 await collectSources(full, nestedRoots, acc); } else if (SOURCE_EXTENSIONS.has(entry.name.slice(entry.name.lastIndexOf('.')))) { acc.push(full); } } return acc; } function extractSpecifiers(text) { const found = new Set(); const patterns = [ /(?:^|[^\w$.])import\s+(?:[^'";]*?\sfrom\s+)?['"]([^'"]+)['"]/g, /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g, /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g, ]; for (const pattern of patterns) { let match; while ((match = pattern.exec(text)) !== null) found.add(match[1]); } return [...found]; } const allowText = (rule) => (rule.allow.length ? rule.allow.join('、') : '无'); /** 返回违规清单;空数组表示通过。 */ export async function findViolations(root) { const packages = await workspacePackages(root); const nestedRoots = new Set(packages.map((rel) => join(root, rel))); const violations = []; for (const rel of packages) { const pkgDir = join(root, rel); const manifestPath = join(pkgDir, 'package.json'); if (!existsSync(manifestPath)) { violations.push({ package: rel, file: `${rel}/package.json`, specifier: '', reason: '工作区目录缺少 package.json' }); continue; } const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); const name = manifest.name ?? rel; const rule = PACKAGE_RULES[name]; if (!rule) { violations.push({ package: name, file: `${rel}/package.json`, specifier: '', reason: '未登记的包名:先在 PACKAGE_RULES 里写明它允许依赖谁' }); continue; } const declared = [ ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.devDependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {}), ]; for (const dep of declared) { if (dep.startsWith('@aps/') && dep !== name && !rule.allow.includes(dep)) { violations.push({ package: name, file: `${rel}/package.json`, specifier: dep, reason: `声明了不允许的依赖(允许:${allowText(rule)})` }); } } for (const file of await collectSources(pkgDir, nestedRoots)) { const relFile = relative(root, file).split(sep).join('/'); const text = await readFile(file, 'utf8'); for (const specifier of extractSpecifiers(text)) { if (specifier.startsWith('@aps/')) { if (specifier === name) continue; if (!rule.allow.includes(specifier)) { violations.push({ package: name, file: relFile, specifier, reason: `不允许引用该包(允许:${allowText(rule)})` }); } continue; } const engineHit = ENGINE_PACKAGES.find((pkg) => specifier === pkg || specifier.startsWith(`${pkg}/`)); if (engineHit) { if (!relFile.startsWith(ENGINE_ALLOWED_PREFIX)) { violations.push({ package: name, file: relFile, specifier, reason: `引擎包只能由 ${ENGINE_ALLOWED_PREFIX} 引用(ADR-0006)` }); } continue; } if (specifier.startsWith('.')) { const target = resolve(dirname(file), specifier); const inside = target === pkgDir || target.startsWith(pkgDir + sep); if (!inside) violations.push({ package: name, file: relFile, specifier, reason: '相对路径逃出包边界' }); } } } } return violations; } async function main() { const root = resolve(process.argv[2] ?? process.cwd()); if (!(await stat(root)).isDirectory()) throw new Error(`${root} 不是目录`); const violations = await findViolations(root); if (violations.length === 0) { console.log(`依赖方向检查通过(根目录:${root})`); return; } console.error(`依赖方向检查发现 ${violations.length} 处违规:`); for (const v of violations) { console.error(` [${v.package}] ${v.file}${v.specifier ? ` → ${v.specifier}` : ''}:${v.reason}`); } process.exitCode = 1; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { await main(); }