feat(agent): ② approve 前硬闸——分项检查(lint/typecheck/build) + diff 体量闸
- checks.ts:runChecks 按 lint→typecheck→build 逐项跑(worktree 内,各 10min, 任一非0=硬失败报哪项挂);parseChecks 解析 project.checks JSON - diffSizeGate:git diff --numstat 统计文件数+增删行数,超阈值=硬失败(提示拆解); 阈值 env 可配 MAESTRO_DIFF_MAX_FILES/LINES(默认 60/3000,0=不限);git 出错不拦截 - pipeline:verify 后插 checks+diff 闸,任一失败 emit failed 不进复审; conflict 管线只跑 checks(diff 含整条原分支,体量闸会误伤) - 注:diff「声明外文件」闸未做——task 无声明文件范围字段 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { parseChecks, runChecks, diffSizeGate } from '../src/executor/checks.js';
|
||||
import type { Project } from '../src/model/types.js';
|
||||
|
||||
function withTmpData(cb: (dir: string) => Promise<void> | void): Promise<void> | void {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'maestro-checks-'));
|
||||
const prev = process.env.MAESTRO_DATA_DIR;
|
||||
process.env.MAESTRO_DATA_DIR = dir;
|
||||
const done = () => {
|
||||
if (prev === undefined) delete process.env.MAESTRO_DATA_DIR;
|
||||
else process.env.MAESTRO_DATA_DIR = prev;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
};
|
||||
try {
|
||||
const r = cb(dir);
|
||||
if (r instanceof Promise) return r.finally(done);
|
||||
done();
|
||||
} catch (e) { done(); throw e; }
|
||||
}
|
||||
|
||||
function proj(checks: string | null): Project {
|
||||
return { checks, repoPath: '/tmp/x', defaultBranch: 'main' } as Project;
|
||||
}
|
||||
|
||||
test('parseChecks:合法 JSON 取 lint/typecheck/build,非法/空 → {}', () => {
|
||||
assert.deepEqual(parseChecks('{"lint":"npm run lint","build":"npm run build"}'), { lint: 'npm run lint', build: 'npm run build' });
|
||||
assert.deepEqual(parseChecks('{"other":"x","typecheck":" tsc "}'), { typecheck: 'tsc' });
|
||||
assert.deepEqual(parseChecks(null), {});
|
||||
assert.deepEqual(parseChecks('not json'), {});
|
||||
assert.deepEqual(parseChecks('{"lint":""}'), {}); // 空命令跳过
|
||||
});
|
||||
|
||||
test('runChecks:无 checks → 通过', async () => {
|
||||
await withTmpData(async (dir) => {
|
||||
const r = await runChecks(proj(null), dir, 'run_a');
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.logRef, null);
|
||||
});
|
||||
});
|
||||
|
||||
test('runChecks:各项 exit0 → 通过;任一非0 → 失败并报哪项挂', async () => {
|
||||
await withTmpData(async (dir) => {
|
||||
const pass = await runChecks(proj('{"lint":"true","typecheck":"true"}'), dir, 'run_b');
|
||||
assert.equal(pass.ok, true);
|
||||
assert.equal(pass.failed, null);
|
||||
|
||||
const fail = await runChecks(proj('{"lint":"true","typecheck":"false","build":"true"}'), dir, 'run_c');
|
||||
assert.equal(fail.ok, false);
|
||||
assert.equal(fail.failed, 'typecheck');
|
||||
assert.match(fail.error!, /typecheck 检查失败/);
|
||||
});
|
||||
});
|
||||
|
||||
test('diffSizeGate:超文件/行阈值 → 拦截;阈值内 → 通过', async () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), 'maestro-gitdiff-'));
|
||||
const g = (args: string[]) => execFileSync('git', args, { cwd: repo }).toString();
|
||||
g(['init', '-q', '-b', 'main']);
|
||||
g(['config', 'user.email', 't@t']); g(['config', 'user.name', 't']);
|
||||
writeFileSync(join(repo, 'base.txt'), 'base\n');
|
||||
g(['add', '-A']); g(['commit', '-qm', 'base']);
|
||||
g(['checkout', '-q', '-b', 'feature']);
|
||||
// 改动:3 个文件、若干行
|
||||
for (const f of ['a.txt', 'b.txt', 'c.txt']) writeFileSync(join(repo, f), 'x\ny\nz\n');
|
||||
g(['add', '-A']); g(['commit', '-qm', 'feat']);
|
||||
|
||||
const prevF = process.env.MAESTRO_DIFF_MAX_FILES;
|
||||
const prevL = process.env.MAESTRO_DIFF_MAX_LINES;
|
||||
try {
|
||||
process.env.MAESTRO_DIFF_MAX_FILES = '2'; // 3 文件 > 2 → 拦截
|
||||
process.env.MAESTRO_DIFF_MAX_LINES = '0';
|
||||
const over = await diffSizeGate(repo, repo, 'feature', 'main');
|
||||
assert.equal(over.ok, false);
|
||||
assert.equal(over.files, 3);
|
||||
assert.match(over.error!, /文件数 3 > 2/);
|
||||
|
||||
process.env.MAESTRO_DIFF_MAX_FILES = '0'; // 0=不限
|
||||
process.env.MAESTRO_DIFF_MAX_LINES = '0';
|
||||
const under = await diffSizeGate(repo, repo, 'feature', 'main');
|
||||
assert.equal(under.ok, true);
|
||||
assert.equal(under.files, 3);
|
||||
} finally {
|
||||
if (prevF === undefined) delete process.env.MAESTRO_DIFF_MAX_FILES; else process.env.MAESTRO_DIFF_MAX_FILES = prevF;
|
||||
if (prevL === undefined) delete process.env.MAESTRO_DIFF_MAX_LINES; else process.env.MAESTRO_DIFF_MAX_LINES = prevL;
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('diffSizeGate:git 出错 → 不拦截(测不出体量)', async () => {
|
||||
const r = await diffSizeGate('/nonexistent-repo-xyz', '/tmp', 'a', 'b');
|
||||
assert.equal(r.ok, true);
|
||||
});
|
||||
@@ -152,6 +152,35 @@ test('verify 失败:runner ok 但 verify !ok → emit failed(沿用 executor
|
||||
assert.equal(emitted.at(-1)?.type, 'done', '末尾应为 done');
|
||||
});
|
||||
|
||||
test('checks 闸失败:verify 过但分项检查 !ok → emit failed(报哪项挂)+ done,无 result,不复审', async () => {
|
||||
const { emitted, emit } = collector();
|
||||
let reviewed = false;
|
||||
await runPipeline(fakeJob, mockDeps({
|
||||
checks: async () => ({ ok: false, failed: 'typecheck', logRef: '/tmp/c.log', error: 'typecheck 检查失败(exit 1):tsc' }),
|
||||
reviewCode: async () => { reviewed = true; return okReview; },
|
||||
}), emit);
|
||||
|
||||
assert.equal(find(emitted, 'result'), undefined, 'checks 失败不应 emit result');
|
||||
assert.equal(reviewed, false, 'checks 失败不应进入复审');
|
||||
const failed = find(emitted, 'failed');
|
||||
assert.ok(failed, '应 emit failed');
|
||||
assert.match(failed.error!, /typecheck 检查失败/);
|
||||
assert.equal(emitted.at(-1)?.type, 'done', '末尾应为 done');
|
||||
});
|
||||
|
||||
test('diff 体量闸失败:emit failed(体量超阈值)+ done,无 result', async () => {
|
||||
const { emitted, emit } = collector();
|
||||
await runPipeline(fakeJob, mockDeps({
|
||||
diffGate: async () => ({ ok: false, files: 99, lines: 9999, error: '改动体量超阈值(文件数 99 > 60):任务可能过大,建议拆解后重做' }),
|
||||
}), emit);
|
||||
|
||||
assert.equal(find(emitted, 'result'), undefined, 'diff 闸失败不应 emit result');
|
||||
const failed = find(emitted, 'failed');
|
||||
assert.ok(failed, '应 emit failed');
|
||||
assert.match(failed.error!, /体量超阈值/);
|
||||
assert.equal(emitted.at(-1)?.type, 'done', '末尾应为 done');
|
||||
});
|
||||
|
||||
test('复审抛错不挡:reviewCode 抛错 → result 仍 emit,code.verdict=null 且 summary 含「自动复审失败」', async () => {
|
||||
const { emitted, emit } = collector();
|
||||
await runPipeline(fakeJob, mockDeps({
|
||||
|
||||
Reference in New Issue
Block a user