feat(ci): lint 基线与三个检查接入门禁(A1.6)

eslint(typescript-eslint + prettier)、跨库访问检查(ADR-0008)、能力描述七要素检查(A2.6 的 CI 项)、APS/平台 CI 分流;新增 pnpm verify 一条命令跑完整门禁。TypeScript 从 7.0 降到 6.0.3——typescript-eslint 尚不支持 TS 7。测试 21 → 30 条全绿。
This commit is contained in:
zh 2026-09-24 15:37:35 +08:00
parent 0a6152e419
commit f127e5071e
10 changed files with 1199 additions and 188 deletions

View File

@ -1,4 +1,6 @@
# CI 基线(任务 A1.2 先接入依赖方向检查;lint 与完整测试矩阵见 A1.6)
# CI 基线(任务 A1.6):平台门禁 + APS 分流
# 平台:typecheck / lint / 依赖方向 / 跨库访问 / 能力描述 / 测试
# APS:目前尚无代码,检测到 aps/ 下有 Python 文件时自动启用(B1 落地后接 ruff + pytest)
name: ci
on:
@ -7,7 +9,7 @@ on:
pull_request:
jobs:
verify:
platform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -19,6 +21,40 @@ jobs:
node-version: '22.19.0'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm check:deps
- run: pnpm test
- name: typecheck
run: pnpm typecheck
- name: lint
run: pnpm lint
- name: 依赖方向检查
run: pnpm check:deps
- name: 跨库访问检查
run: pnpm check:db
- name: 能力描述检查
run: pnpm check:capabilities
- name: 测试(契约 / 依赖方向 / 跨库 / 能力描述 / 服务端冒烟)
run: pnpm test
aps:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 检测 APS 代码
id: detect
run: |
if compgen -G "aps/**/*.py" > /dev/null; then
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
echo "aps/ 下暂无 Python 代码,跳过(B1 落地后自动启用)"
fi
- uses: actions/setup-python@v5
if: steps.detect.outputs.found == 'true'
with:
python-version: '3.11'
- name: APS 检查(ruff + pytest)
if: steps.detect.outputs.found == 'true'
run: |
python -m pip install --upgrade pip
pip install ruff pytest
ruff check aps
pytest aps -q

6
.prettierrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"printWidth": 140,
"singleQuote": true,
"trailingComma": "all",
"semi": true
}

21
eslint.config.js Normal file
View File

@ -0,0 +1,21 @@
// 平台代码的 lint 基线(任务 A1.6)。
// 只覆盖平台 TS(contracts / server / client / providers);aps/ 是 Python,脚本是纯 JS 测试,都不走这套。
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['**/dist/**', '**/node_modules/**', 'aps/**', 'scripts/**', '**/*.mjs', '**/*.cjs'],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx'],
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
eqeqeq: ['error', 'always'],
'no-console': 'off',
},
},
);

View File

@ -10,12 +10,21 @@
"build": "tsc -b",
"clean": "tsc -b --clean",
"check:deps": "node scripts/check-dependency-direction.mjs",
"check:db": "node scripts/check-cross-database.mjs",
"check:capabilities": "node scripts/check-capability-description.mjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"verify": "pnpm typecheck && pnpm lint && pnpm check:deps && pnpm check:db && pnpm check:capabilities && pnpm test",
"test": "tsc -b && node --test \"scripts/**/*.test.mjs\"",
"dev:server": "tsc -b && node server/dist/bin/serve.js"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^26.6.2",
"ajv": "^8.20.0",
"typescript": "^7.0.2"
"eslint": "^10.11.0",
"prettier": "^3.9.9",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.1"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,104 @@
#!/usr/bin/env node
/**
* 能力描述检查(任务 A1.6,依据 docs/spec/capability-description.md)。
*
* 约定:Provider 的每个能力写成一个 `*.capability.json`,放在 providers/<id>/ 下。
* 七要素缺一即失败——模型选工具靠的就是这份描述(平台不另建意图识别器)。
*
* 用法:node scripts/check-capability-description.mjs [仓库根目录]
*/
import { existsSync } from 'node:fs';
import { readFile, readdir } from 'node:fs/promises';
import { join, relative, sep } from 'node:path';
import process from 'node:process';
import { pathToFileURL } from 'node:url';
const SCAN_DIRS = ['providers'];
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git']);
const ID_PATTERN = /^[a-z0-9]+(\.[a-z0-9-]+)+$/;
const EFFECTS = new Set(['read', 'draft', 'write']);
const PURPOSE_MAX = 40;
async function collectCapabilityFiles(dir, 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;
await collectCapabilityFiles(full, acc);
} else if (entry.name.endsWith('.capability.json')) {
acc.push(full);
}
}
return acc;
}
const nonEmptyString = (value) => typeof value === 'string' && value.trim() !== '';
/** 校验单个能力描述对象,返回问题清单 */
export function validateCapability(description) {
const problems = [];
if (!nonEmptyString(description.id) || !ID_PATTERN.test(description.id)) {
problems.push('id 必须形如 aps.schedule.preview(小写点分)');
}
if (!nonEmptyString(description.providerId)) problems.push('缺 providerId');
if (!nonEmptyString(description.title)) problems.push('缺 title');
if (!nonEmptyString(description.purpose)) {
problems.push('缺 purpose(一句话用途)');
} else if (description.purpose.length > PURPOSE_MAX) {
problems.push(`purpose 超过 ${PURPOSE_MAX} 字`);
}
if (!nonEmptyString(description.whenNotToUse)) problems.push('缺 whenNotToUse(何时不用)');
if (!EFFECTS.has(description.effect)) problems.push('effect 必须是 read / draft / write');
if (typeof description.inputSchema !== 'object' || description.inputSchema === null) problems.push('缺 inputSchema');
if (!Array.isArray(description.synonyms) || description.synonyms.filter(nonEmptyString).length === 0) {
problems.push('synonyms 至少一条(业务黑话与同义词)');
}
const example = description.example;
if (typeof example !== 'object' || example === null || !nonEmptyString(example.utterance) || !nonEmptyString(example.expects)) {
problems.push('example 必须含 utterance 与 expects');
}
return problems;
}
export async function findCapabilityProblems(root) {
const findings = [];
for (const dir of SCAN_DIRS) {
const abs = join(root, dir);
if (!existsSync(abs)) continue;
for (const file of await collectCapabilityFiles(abs)) {
const rel = relative(root, file).split(sep).join('/');
let parsed;
try {
parsed = JSON.parse(await readFile(file, 'utf8'));
} catch (error) {
findings.push({ file: rel, problems: [`JSON 解析失败:${error.message}`] });
continue;
}
const problems = validateCapability(parsed);
if (problems.length > 0) findings.push({ file: rel, problems });
}
}
return findings;
}
async function main() {
const root = process.cwd();
const findings = await findCapabilityProblems(root);
if (findings.length === 0) {
console.log('能力描述检查通过(当前没有能力描述文件时为正常通过)');
return;
}
console.error(`能力描述检查发现 ${findings.length} 个文件有问题:`);
for (const finding of findings) console.error(` ${finding.file}:${finding.problems.join(';')}`);
process.exitCode = 1;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}

View File

@ -0,0 +1,46 @@
/** 能力描述检查的用例(任务 A1.6)。 */
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { validateCapability } from './check-capability-description.mjs';
const valid = {
id: 'aps.schedule.preview',
providerId: 'aps',
title: '试排一版',
purpose: '按当前项目数据试排一版方案,只出结果不发布',
whenNotToUse: '要落库生效请用 aps.schedule.publish',
effect: 'read',
inputSchema: { type: 'object' },
synonyms: ['试排', '跑一版'],
example: { utterance: '跑一版看看', expects: 'aps.schedule.preview' },
};
test('正例:七要素齐备通过', () => {
assert.deepEqual(validateCapability(valid), []);
});
test('负例:缺「何时不用」被拦下', () => {
const problems = validateCapability({ ...valid, whenNotToUse: '' });
assert.ok(problems.some((p) => p.includes('whenNotToUse')), problems.join(';'));
});
test('负例:id 命名不合规被拦下', () => {
const problems = validateCapability({ ...valid, id: 'doSchedule' });
assert.ok(problems.some((p) => p.includes('id')), problems.join(';'));
});
test('负例:purpose 超长被拦下', () => {
const problems = validateCapability({ ...valid, purpose: '一'.repeat(41) });
assert.ok(problems.some((p) => p.includes('40')), problems.join(';'));
});
test('负例:effect 取值非法被拦下', () => {
const problems = validateCapability({ ...valid, effect: 'execute' });
assert.ok(problems.some((p) => p.includes('effect')), problems.join(';'));
});
test('负例:缺同义词与示例被拦下', () => {
const problems = validateCapability({ ...valid, synonyms: [], example: {} });
assert.equal(problems.length, 2, problems.join(';'));
});

View File

@ -0,0 +1,93 @@
#!/usr/bin/env node
/**
* 跨库访问检查(任务 A1.6,依据 ADR-0008)。
*
* 平台代码禁止直连 APS 库:库各自私有,平台只能经 Provider 契约访问业务系统。
* 本脚本扫平台源码,出现下面任一项即失败:
* 1. APS 侧的表名(平台侧不该认识这些表)
* 2. 指向 APS 库的连接串或库名
*
* 用法:node scripts/check-cross-database.mjs [仓库根目录]
*/
import { existsSync } from 'node:fs';
import { readFile, readdir } from 'node:fs/promises';
import { join, relative, sep } from 'node:path';
import process from 'node:process';
import { pathToFileURL } from 'node:url';
/** 平台包目录:只有这些地方的代码属于平台 */
export const PLATFORM_DIRS = ['contracts', 'server', 'client', 'providers'];
/** APS 侧的表名(ADR-0008:APS 6 张表) */
export const APS_TABLES = [
'master_records',
'project_settings',
'routing_templates',
'routing_template_steps',
'aps_schedule_runs',
'aps_schedule_results',
];
/** 指向 APS 库的连接串特征 */
export const APS_DATABASE_HINTS = [/aps[_-]?db/i, /database\s*=\s*aps/i, /jms_mes/i];
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs']);
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'coverage']);
async function collectSources(dir, 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;
await collectSources(full, acc);
} else if (SOURCE_EXTENSIONS.has(entry.name.slice(entry.name.lastIndexOf('.')))) {
acc.push(full);
}
}
return acc;
}
export async function findCrossDatabaseAccess(root) {
const violations = [];
for (const dir of PLATFORM_DIRS) {
const abs = join(root, dir);
if (!existsSync(abs)) continue;
for (const file of await collectSources(abs)) {
const rel = relative(root, file).split(sep).join('/');
const text = await readFile(file, 'utf8');
for (const table of APS_TABLES) {
if (new RegExp(`\\b${table}\\b`).test(text)) {
violations.push({ file: rel, reason: `出现 APS 表名 ${table}(平台不该认识业务系统的表)` });
}
}
for (const hint of APS_DATABASE_HINTS) {
if (hint.test(text)) {
violations.push({ file: rel, reason: `出现指向 APS 库的线索:${hint}` });
}
}
}
}
return violations;
}
async function main() {
const root = process.cwd();
const violations = await findCrossDatabaseAccess(root);
if (violations.length === 0) {
console.log('跨库访问检查通过:平台代码未出现 APS 表名或 APS 库连接');
return;
}
console.error(`跨库访问检查发现 ${violations.length} 处:`);
for (const violation of violations) console.error(` ${violation.file}:${violation.reason}`);
process.exitCode = 1;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}

View File

@ -0,0 +1,49 @@
/** 跨库访问检查的用例(任务 A1.6):负例必须真的失败。 */
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, 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 { findCrossDatabaseAccess } from './check-cross-database.mjs';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
async function withFixture(files, run) {
const dir = await mkdtemp(join(tmpdir(), 'crossdb-'));
try {
for (const [rel, content] of Object.entries(files)) {
const file = join(dir, rel);
await mkdir(dirname(file), { recursive: true });
await writeFile(file, content, 'utf8');
}
return await run(dir);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
test('正例:当前仓库无跨库访问', async () => {
assert.deepEqual(await findCrossDatabaseAccess(ROOT), []);
});
test('负例:平台代码里出现 APS 表名被拦下', async () => {
await withFixture(
{ 'server/core/query.ts': 'const sql = "select * from routing_templates";\nexport const q = sql;\n' },
async (dir) => {
const violations = await findCrossDatabaseAccess(dir);
assert.ok(violations.some((v) => v.reason.includes('routing_templates')), JSON.stringify(violations));
},
);
});
test('负例:平台代码里出现 APS 库连接串被拦下', async () => {
await withFixture(
{ 'server/app/config.ts': 'export const url = "postgres://user:pw@db/aps_db";\n' },
async (dir) => {
const violations = await findCrossDatabaseAccess(dir);
assert.ok(violations.length > 0, JSON.stringify(violations));
},
);
});

View File

@ -4,7 +4,7 @@
* 它刻意不做任何「理解」,也不调用 Provider——避免把假能力写进真实链路。
* A3.1 定义正式 AgentRuntime、A3.2 写 FakeRuntime 之后,本文件整体替换。
*/
import { EventBus } from './events.js';
import type { EventBus } from './events.js';
export interface EchoRunInput {
sessionId: string;