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

176 lines
6.7 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
/**
* 依赖方向检查(任务 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();
}