feat(ci): 依赖方向检查与负例用例接入 CI(A1.2)

scripts/check-dependency-direction.mjs 拦三类反向依赖:包之间越界(core 引用具体 Provider、业务包直连 Provider、客户端引用服务端)、相对路径逃出包边界、引擎包被 server/agent 之外引用(ADR-0006)。八条用例含六个负例,node --test 全绿;CI 跑 install / typecheck / check:deps / test。lint 与完整测试矩阵留待 A1.6。
This commit is contained in:
zh 2026-09-23 19:31:13 +08:00
parent d2aa837579
commit ff31e7baa2
4 changed files with 325 additions and 1 deletions

24
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,24 @@
# CI 基线(任务 A1.2 先接入依赖方向检查;lint 与完整测试矩阵见 A1.6)
name: ci
on:
push:
branches: [rewrite/platform, main]
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 11
- uses: actions/setup-node@v4
with:
node-version: '22.19.0'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm check:deps
- run: pnpm test

View File

@ -8,7 +8,9 @@
"scripts": { "scripts": {
"typecheck": "tsc -b", "typecheck": "tsc -b",
"build": "tsc -b", "build": "tsc -b",
"clean": "tsc -b --clean" "clean": "tsc -b --clean",
"check:deps": "node scripts/check-dependency-direction.mjs",
"test": "node --test \"scripts/**/*.test.mjs\""
}, },
"devDependencies": { "devDependencies": {
"typescript": "^7.0.2" "typescript": "^7.0.2"

View File

@ -0,0 +1,176 @@
#!/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();
}

View File

@ -0,0 +1,122 @@
/**
* 依赖方向检查的负例用例(任务 A1.2 的验证方式)。
* 每个负例都要求检查器真的报错——只测正例等于没测。
*/
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { findViolations } from './check-dependency-direction.mjs';
const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url));
const WORKSPACE = ['contracts', 'server', 'server/provider-sdk', 'providers/*', 'client/*'];
/** 造一个最小工作区;extra 覆盖或新增文件(键为相对路径)。 */
async function makeWorkspace(extra = {}) {
const root = await mkdtemp(join(tmpdir(), 'depcheck-'));
const files = {
'pnpm-workspace.yaml': `packages:\n${WORKSPACE.map((p) => ` - ${p}\n`).join('')}`,
'contracts/package.json': JSON.stringify({ name: '@aps/contracts' }),
'server/package.json': JSON.stringify({ name: '@aps/server', dependencies: { '@aps/contracts': 'workspace:*', '@aps/provider-sdk': 'workspace:*' } }),
'server/provider-sdk/package.json': JSON.stringify({ name: '@aps/provider-sdk', dependencies: { '@aps/contracts': 'workspace:*' } }),
'providers/aps/package.json': JSON.stringify({ name: '@aps/provider-aps', dependencies: { '@aps/provider-sdk': 'workspace:*' } }),
'client/app/package.json': JSON.stringify({ name: '@aps/client-app', dependencies: { '@aps/contracts': 'workspace:*' } }),
...extra,
};
for (const [rel, content] of Object.entries(files)) {
const file = join(root, rel);
await mkdir(dirname(file), { recursive: true });
await writeFile(file, content, 'utf8');
}
return root;
}
async function withWorkspace(extra, run) {
const root = await makeWorkspace(extra);
try {
return await run(root);
} finally {
await rm(root, { recursive: true, force: true });
}
}
const reasons = (violations) => violations.map((v) => `${v.file} → ${v.specifier}:${v.reason}`).join('\n');
test('正例:当前仓库无违规', async () => {
assert.deepEqual(await findViolations(REPO_ROOT), []);
});
test('负例:core 引用具体 Provider 被拦下', async () => {
await withWorkspace(
{
'server/core/router.ts': "import type { CapabilityProvider } from '@aps/provider-aps';\nexport type X = CapabilityProvider;\n",
},
async (root) => {
const violations = await findViolations(root);
assert.ok(violations.some((v) => v.file.endsWith('server/core/router.ts') && v.specifier === '@aps/provider-aps'), reasons(violations));
},
);
});
test('负例:业务包直连 Provider 被拦下', async () => {
await withWorkspace(
{ 'client/app/direct.ts': "import '@aps/provider-aps';\n" },
async (root) => {
const violations = await findViolations(root);
assert.ok(violations.some((v) => v.package === '@aps/client-app' && v.specifier === '@aps/provider-aps'), reasons(violations));
},
);
});
test('负例:客户端引用服务端被拦下', async () => {
await withWorkspace(
{ 'client/app/server-call.ts': "import type { ExecutionReceipt } from '@aps/server';\nexport type R = ExecutionReceipt;\n" },
async (root) => {
const violations = await findViolations(root);
assert.ok(violations.some((v) => v.specifier === '@aps/server'), reasons(violations));
},
);
});
test('负例:非 agent 模块引用引擎包被拦下', async () => {
await withWorkspace(
{ 'server/entry/boot.ts': "import '@earendil-works/pi-agent-core';\n" },
async (root) => {
const violations = await findViolations(root);
assert.ok(violations.some((v) => v.file.endsWith('server/entry/boot.ts')), reasons(violations));
},
);
});
test('正例:server/agent 引用引擎包放行', async () => {
await withWorkspace(
{ 'server/agent/runtime.ts': "import '@earendil-works/pi-agent-core';\n" },
async (root) => {
assert.deepEqual(await findViolations(root), []);
},
);
});
test('负例:相对路径逃出包边界被拦下', async () => {
await withWorkspace(
{ 'client/app/escape.ts': "import '../../../providers/aps/index.js';\n" },
async (root) => {
const violations = await findViolations(root);
assert.ok(violations.some((v) => v.reason === '相对路径逃出包边界'), reasons(violations));
},
);
});
test('负例:未登记的包名被拦下', async () => {
await withWorkspace(
{ 'providers/erp/package.json': JSON.stringify({ name: '@aps/provider-erp' }) },
async (root) => {
const violations = await findViolations(root);
assert.ok(violations.some((v) => v.reason.includes('未登记的包名')), reasons(violations));
},
);
});