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:
wangjia
2026-06-24 22:53:56 +08:00
parent ceb4f9e9d2
commit 2d127698d1
4 changed files with 300 additions and 0 deletions
+137
View File
@@ -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.checksJSON 文本);非法/空 → 空配置。只取 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 = 硬失败(报哪项挂)。
* 独立于 verifyCmdverify 可跑测试,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 };
}
+38
View File
@@ -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 交给 outboxworker 传 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);
+96
View File
@@ -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('diffSizeGategit 出错 → 不拦截(测不出体量)', async () => {
const r = await diffSizeGate('/nonexistent-repo-xyz', '/tmp', 'a', 'b');
assert.equal(r.ok, true);
});
+29
View File
@@ -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 仍 emitcode.verdict=null 且 summary 含「自动复审失败」', async () => {
const { emitted, emit } = collector();
await runPipeline(fakeJob, mockDeps({