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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user