aps-agent/scripts/check-dependency-direction.mjs

180 lines
7.0 KiB
JavaScript
Raw Normal View History

#!/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('、') : '无');
/** 子路径导入(@aps/provider-sdk/testing)算同一个包 */
const packageNameOf = (specifier) => (specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0]);
/** 返回违规清单;空数组表示通过。 */
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/')) {
const base = packageNameOf(specifier);
if (base === name) continue;
if (!rule.allow.includes(base)) {
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();
}