diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d987275 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 \ No newline at end of file diff --git a/package.json b/package.json index ed48657..85285ab 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "scripts": { "typecheck": "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": { "typescript": "^7.0.2" diff --git a/scripts/check-dependency-direction.mjs b/scripts/check-dependency-direction.mjs new file mode 100644 index 0000000..fef8825 --- /dev/null +++ b/scripts/check-dependency-direction.mjs @@ -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(); +} \ No newline at end of file diff --git a/scripts/check-dependency-direction.test.mjs b/scripts/check-dependency-direction.test.mjs new file mode 100644 index 0000000..7e54ed6 --- /dev/null +++ b/scripts/check-dependency-direction.test.mjs @@ -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)); + }, + ); +}); \ No newline at end of file