176 lines
6.7 KiB
JavaScript
176 lines
6.7 KiB
JavaScript
|
|
#!/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();
|
|||
|
|
}
|