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,137 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdirSync, appendFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { Project } from '../model/types.js';
|
||||
import { transcriptDir } from './runner.js';
|
||||
import { git } from './worktree.js';
|
||||
|
||||
// ───────────────────────── 分项检查闸(lint / typecheck / build)─────────────────────────
|
||||
|
||||
/** project.checks 解析后的命令表(缺省项跳过)。 */
|
||||
export interface ChecksConfig {
|
||||
lint?: string;
|
||||
typecheck?: string;
|
||||
build?: string;
|
||||
}
|
||||
|
||||
export interface ChecksResult {
|
||||
ok: boolean;
|
||||
failed: string | null; // 第一个失败的检查项名(lint/typecheck/build);全过=null
|
||||
logRef: string | null; // <transcriptDir>/<runId>.checks.log;无 checks 时 null
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** 校验函数签名(pipeline 依赖注入点,测试可 mock)。 */
|
||||
export type ChecksFn = (project: Project, dir: string, runId: string) => Promise<ChecksResult>;
|
||||
|
||||
const CHECK_TIMEOUT_MS = 10 * 60_000; // 单项 10min
|
||||
|
||||
/** 解析 project.checks(JSON 文本);非法/空 → 空配置。只取 lint/typecheck/build 三项字符串。 */
|
||||
export function parseChecks(raw: string | null | undefined): ChecksConfig {
|
||||
if (!raw?.trim()) return {};
|
||||
try {
|
||||
const obj = JSON.parse(raw) as Record<string, unknown>;
|
||||
const out: ChecksConfig = {};
|
||||
for (const k of ['lint', 'typecheck', 'build'] as const) {
|
||||
const v = obj[k];
|
||||
if (typeof v === 'string' && v.trim()) out[k] = v.trim();
|
||||
}
|
||||
return out;
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
function runOne(cmd: string, dir: string): Promise<{ ok: boolean; exit: number; stdout: string; stderr: string; timedOut: boolean }> {
|
||||
return new Promise((resolve) => {
|
||||
execFile('/bin/sh', ['-c', cmd], { cwd: dir, timeout: CHECK_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
|
||||
const rawCode = err ? (err as { code?: unknown }).code : 0;
|
||||
const exit = typeof rawCode === 'number' ? rawCode : (err ? 1 : 0);
|
||||
resolve({ ok: !err, exit, stdout, stderr, timedOut: (err as { killed?: boolean } | null)?.killed === true });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 分项静态检查闸:按 lint → typecheck → build 顺序在 worktree 内逐项跑,任一非 0 = 硬失败(报哪项挂)。
|
||||
* 独立于 verifyCmd(verify 可跑测试,checks 跑静态门禁)。无 checks 配置 → 视为通过。
|
||||
*/
|
||||
export async function runChecks(project: Project, dir: string, runId: string): Promise<ChecksResult> {
|
||||
const cfg = parseChecks(project.checks);
|
||||
const items = (['lint', 'typecheck', 'build'] as const).filter((k) => cfg[k]);
|
||||
if (items.length === 0) return { ok: true, failed: null, logRef: null };
|
||||
|
||||
const logDir = transcriptDir();
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
const logRef = join(logDir, `${runId}.checks.log`);
|
||||
writeFileSync(logRef, `# 分项检查(lint/typecheck/build)\n`);
|
||||
|
||||
for (const name of items) {
|
||||
const cmd = cfg[name]!;
|
||||
const r = await runOne(cmd, dir);
|
||||
appendFileSync(logRef, `\n## ${name}: $ ${cmd}\n--- stdout ---\n${r.stdout}\n--- stderr ---\n${r.stderr}\n--- exit: ${r.exit} ---\n`);
|
||||
if (!r.ok) {
|
||||
const error = r.timedOut
|
||||
? `${name} 检查超时(>${CHECK_TIMEOUT_MS / 1000}s):${cmd}`
|
||||
: `${name} 检查失败(exit ${r.exit}):${cmd}`;
|
||||
return { ok: false, failed: name, logRef, error };
|
||||
}
|
||||
}
|
||||
return { ok: true, failed: null, logRef };
|
||||
}
|
||||
|
||||
// ───────────────────────── diff 体量闸 ─────────────────────────
|
||||
|
||||
export interface DiffGateResult {
|
||||
ok: boolean;
|
||||
files: number;
|
||||
lines: number; // 增 + 删 总行数
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** 体量阈值:env 覆盖,0=不限。默认值放得较宽,仅拦截"明显过大/应先拆解"的改动。 */
|
||||
function diffLimits(): { maxFiles: number; maxLines: number } {
|
||||
const num = (v: string | undefined, dflt: number): number => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) && n >= 0 ? n : dflt;
|
||||
};
|
||||
return {
|
||||
maxFiles: num(process.env.MAESTRO_DIFF_MAX_FILES, 60),
|
||||
maxLines: num(process.env.MAESTRO_DIFF_MAX_LINES, 3000),
|
||||
};
|
||||
}
|
||||
|
||||
/** diff 体量函数签名(pipeline 依赖注入点)。 */
|
||||
export type DiffGateFn = (repoPath: string, dir: string, branch: string, baseBranch: string) => Promise<DiffGateResult>;
|
||||
|
||||
/**
|
||||
* diff 体量闸:用 git diff --numstat 统计改动文件数 + 增删总行数,超阈值 = 硬失败
|
||||
* (提示任务可能过大、应先拆解)。阈值由 env 配置,置 0 = 不限(关闸)。二进制文件行数计 0。
|
||||
*/
|
||||
export async function diffSizeGate(repoPath: string, _dir: string, branch: string, baseBranch: string): Promise<DiffGateResult> {
|
||||
let out: string;
|
||||
try {
|
||||
out = (await git(repoPath, ['diff', '--numstat', `${baseBranch}...${branch}`])).trim();
|
||||
} catch {
|
||||
return { ok: true, files: 0, lines: 0 }; // 测不出体量(git 出错)→ 不拦截
|
||||
}
|
||||
let files = 0;
|
||||
let lines = 0;
|
||||
if (out) {
|
||||
for (const row of out.split('\n')) {
|
||||
const [add, del] = row.split('\t');
|
||||
files++;
|
||||
const a = Number(add); const d = Number(del); // 二进制文件为 '-' → NaN → 计 0
|
||||
lines += (Number.isFinite(a) ? a : 0) + (Number.isFinite(d) ? d : 0);
|
||||
}
|
||||
}
|
||||
const { maxFiles, maxLines } = diffLimits();
|
||||
const overFiles = maxFiles > 0 && files > maxFiles;
|
||||
const overLines = maxLines > 0 && lines > maxLines;
|
||||
if (overFiles || overLines) {
|
||||
const parts = [
|
||||
overFiles ? `文件数 ${files} > ${maxFiles}` : '',
|
||||
overLines ? `改动行数 ${lines} > ${maxLines}` : '',
|
||||
].filter(Boolean).join(';');
|
||||
return { ok: false, files, lines, error: `改动体量超阈值(${parts}):任务可能过大,建议拆解后重做` };
|
||||
}
|
||||
return { ok: true, files, lines };
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { createWorktree, worktreeDiff, git, type WorktreeDiff, type WorktreeInfo
|
||||
import { runTask, runPlanner, runConflict, type RunnerFn, type PlannerFn } from './runner.js';
|
||||
import { runVerify, type VerifyFn } from './verify.js';
|
||||
import { reviewCode, reviewSecurity, type ReviewerFn } from './reviewer.js';
|
||||
import { runChecks, diffSizeGate, type ChecksFn, type DiffGateFn } from './checks.js';
|
||||
|
||||
/** pipeline 的依赖注入面(便于单测 mock);默认值取真实现。 */
|
||||
export interface PipelineDeps {
|
||||
@@ -32,6 +33,10 @@ export interface PipelineDeps {
|
||||
runConflict: typeof runConflict; // 解冲突(conflict run)
|
||||
/** 执行前同步 defaultBranch:返回 null=成功,string=错误信息(已 abort)。可测试 mock。 */
|
||||
syncMain?: (dir: string, defaultBranch: string) => Promise<string | null>;
|
||||
/** 分项检查闸(lint/typecheck/build),默认 checks.runChecks。 */
|
||||
checks?: ChecksFn;
|
||||
/** diff 体量闸,默认 checks.diffSizeGate。 */
|
||||
diffGate?: DiffGateFn;
|
||||
}
|
||||
|
||||
/** 执行前同步 defaultBranch 默认实现:fetch→merge origin/<branch>;失败再试本地 <branch>。 */
|
||||
@@ -53,6 +58,18 @@ async function defaultSyncMain(dir: string, defaultBranch: string): Promise<stri
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* approve 前硬门禁:分项检查闸(lint/typecheck/build)+ diff 体量闸。
|
||||
* 返回 null=全过;string=失败原因(调用方据此 emit failed,不进复审)。
|
||||
*/
|
||||
async function runApproveGates(job: JobSpec, wt: WorktreeInfo, deps: PipelineDeps): Promise<string | null> {
|
||||
const cr = await (deps.checks ?? runChecks)(job.project, wt.dir, job.runId);
|
||||
if (!cr.ok) return cr.error ?? `分项检查失败:${cr.failed}`;
|
||||
const dg = await (deps.diffGate ?? diffSizeGate)(job.project.repoPath, wt.dir, wt.branch, job.project.defaultBranch);
|
||||
if (!dg.ok) return dg.error ?? 'diff 体量超阈值';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 真实依赖(worker 生产用)。 */
|
||||
export const realDeps: PipelineDeps = {
|
||||
createWorktree,
|
||||
@@ -63,6 +80,8 @@ export const realDeps: PipelineDeps = {
|
||||
reviewSecurity,
|
||||
runPlanner,
|
||||
runConflict,
|
||||
checks: runChecks,
|
||||
diffGate: diffSizeGate,
|
||||
};
|
||||
|
||||
/** emit:把一条 OutboxPayload 交给 outbox(worker 传 appendOutbox 包装,测试传收集器)。 */
|
||||
@@ -149,6 +168,15 @@ export async function runPipeline(job: JobSpec, deps: PipelineDeps = realDeps, e
|
||||
return;
|
||||
}
|
||||
|
||||
// 3b. 分项检查闸(lint/typecheck/build)+ diff 体量闸——approve 前的硬门禁
|
||||
emit({ type: 'phase', phase: 'checking' });
|
||||
const gateErr = await runApproveGates(job, wt, deps);
|
||||
if (gateErr !== null) {
|
||||
emit({ type: 'failed', error: gateErr, transcriptRef: rr.transcriptRef, sessionId: rr.sessionId });
|
||||
emit({ type: 'done' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. diff
|
||||
const diff = await deps.worktreeDiff(job.project.repoPath, wt.dir, wt.branch, job.project.defaultBranch);
|
||||
|
||||
@@ -254,6 +282,16 @@ async function runConflictPipeline(job: JobSpec, deps: PipelineDeps, emit: Emit)
|
||||
return;
|
||||
}
|
||||
|
||||
// 4b. 分项检查闸(解冲突后代码须仍通过 lint/typecheck/build)。
|
||||
// 不走 diff 体量闸:解冲突的 diff 含整条原分支改动,体量天然大,闸会误伤。
|
||||
emit({ type: 'phase', phase: 'checking' });
|
||||
const cr = await (deps.checks ?? runChecks)(job.project, wt.dir, job.runId);
|
||||
if (!cr.ok) {
|
||||
emit({ type: 'failed', error: cr.error ?? `分项检查失败:${cr.failed}`, transcriptRef: rr.transcriptRef, sessionId: rr.sessionId });
|
||||
emit({ type: 'done' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. diff
|
||||
const diff = await deps.worktreeDiff(job.project.repoPath, wt.dir, wt.branch, job.project.defaultBranch);
|
||||
|
||||
|
||||
@@ -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