diff --git a/src/executor/checks.ts b/src/executor/checks.ts new file mode 100644 index 0000000..d565aa7 --- /dev/null +++ b/src/executor/checks.ts @@ -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; // /.checks.log;无 checks 时 null + error?: string; +} + +/** 校验函数签名(pipeline 依赖注入点,测试可 mock)。 */ +export type ChecksFn = (project: Project, dir: string, runId: string) => Promise; + +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; + 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 { + 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; + +/** + * diff 体量闸:用 git diff --numstat 统计改动文件数 + 增删总行数,超阈值 = 硬失败 + * (提示任务可能过大、应先拆解)。阈值由 env 配置,置 0 = 不限(关闸)。二进制文件行数计 0。 + */ +export async function diffSizeGate(repoPath: string, _dir: string, branch: string, baseBranch: string): Promise { + 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 }; +} diff --git a/src/executor/pipeline.ts b/src/executor/pipeline.ts index 3acece0..2a12f9f 100644 --- a/src/executor/pipeline.ts +++ b/src/executor/pipeline.ts @@ -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; + /** 分项检查闸(lint/typecheck/build),默认 checks.runChecks。 */ + checks?: ChecksFn; + /** diff 体量闸,默认 checks.diffSizeGate。 */ + diffGate?: DiffGateFn; } /** 执行前同步 defaultBranch 默认实现:fetch→merge origin/;失败再试本地 。 */ @@ -53,6 +58,18 @@ async function defaultSyncMain(dir: string, defaultBranch: string): Promise { + 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); diff --git a/test/checks.test.ts b/test/checks.test.ts new file mode 100644 index 0000000..54285c3 --- /dev/null +++ b/test/checks.test.ts @@ -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): Promise | 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); +}); diff --git a/test/pipeline.test.ts b/test/pipeline.test.ts index 99ee067..7b68055 100644 --- a/test/pipeline.test.ts +++ b/test/pipeline.test.ts @@ -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({