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);