diff --git a/src/fable5/index.ts b/src/fable5/index.ts index dd32d46..802bb6d 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -45,6 +45,8 @@ export type { ToolPolicy, EffectiveToolPolicy } from "./orchestrator-policy.js"; export { appendZteReceipt, createZteReceipt, createZteSpec, validateGateReceipt } from "./zte-protocol.js"; export type { GateReceiptOptions, GateReceiptValidation, ZteReceipt, ZteSpec, ZteSpecOptions } from "./zte-protocol.js"; +export { runVerifyGate, verifyAgentTask, verifyAndClean } from "./verify-gate.js"; +export type { VerifyGateSpec, VerifyGateResult } from "./verify-gate.js"; export { formatCapabilityReport, loadFactoryCapabilities, parseFactoryCapabilities, probeFactoryCapabilities } from "./factory-capabilities.js"; export type { CapabilityProbeResult, CapabilityReport, FactoryCapabilityService } from "./factory-capabilities.js"; diff --git a/src/fable5/verify-gate.test.ts b/src/fable5/verify-gate.test.ts new file mode 100644 index 0000000..a4902b6 --- /dev/null +++ b/src/fable5/verify-gate.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import { runVerifyGate, verifyAndClean, type VerifyGateSpec } from "./verify-gate.js"; + +describe("verify gate", () => { + const repoRoot = process.cwd(); + + it("build then test gate passes on the fable-agent repo itself", () => { + const spec: VerifyGateSpec = { + buildCmd: ["node", "-e", "process.exit(0)"], + testCmd: ["node", "-e", "console.log('1 passed, 0 failed'); process.exit(0)"], + cwd: repoRoot, + }; + const result = runVerifyGate(spec); + expect(result.verdict).toBe("pass"); + expect(result.build.pass).toBe(true); + expect(result.test.pass).toBe(true); + expect(result.test.passed).toBe(1); + expect(result.test.failed).toBe(0); + expect(result.durationMs).toBeGreaterThan(0); + }); + + it("build reject if no .git directory", () => { + const tmp = fs.mkdtempSync("nogit-"); + const result = runVerifyGate({ + buildCmd: ["echo", "ok"], + testCmd: ["echo", "ok"], + cwd: tmp, + }); + expect(result.verdict).toBe("fail"); + expect(result.build.pass).toBe(false); + expect(result.build.exitCode).toBe(-1); + fs.rmdirSync(tmp); + }); + + it("build fail short-circuits test", () => { + const spec: VerifyGateSpec = { + buildCmd: ["false"], + testCmd: ["echo", "should-not-run"], + cwd: repoRoot, + }; + const result = runVerifyGate(spec); + expect(result.verdict).toBe("fail"); + expect(result.build.pass).toBe(false); + expect(result.test.output).toBe(""); + }); + + it("reports test failure counts correctly", () => { + const spec: VerifyGateSpec = { + buildCmd: ["node", "-e", "process.exit(0)"], + testCmd: ["node", "-e", ` + console.log(" 1 passed, 1 failed"); + process.exit(1); + `], + cwd: repoRoot, + }; + const result = runVerifyGate(spec); + expect(result.verdict).toBe("fail"); + expect(result.test.failed).toBe(1); + expect(result.test.passed).toBe(1); + }); + + it("verifyAndClean removes worktree on fail", () => { + const spec: VerifyGateSpec = { + buildCmd: ["false"], + testCmd: ["echo", "x"], + cwd: repoRoot, + }; + const out = verifyAndClean(spec, "test-agent-worktree", null as any); + expect(out.passed).toBe(false); + }); +}); diff --git a/src/fable5/verify-gate.ts b/src/fable5/verify-gate.ts new file mode 100644 index 0000000..c53cdbe --- /dev/null +++ b/src/fable5/verify-gate.ts @@ -0,0 +1,102 @@ +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { WorktreeManager } from "./worktree-isolation.js"; + +export interface VerifyGateSpec { + buildCmd: string[]; + testCmd: string[]; + lintCmd?: string[]; + cwd: string; +} + +export interface VerifyGateResult { + build: { pass: boolean; exitCode: number; output: string }; + lint?: { pass: boolean; exitCode: number; output: string }; + test: { pass: boolean; exitCode: number; output: string; passed: number; failed: number; total: number }; + verdict: "pass" | "fail"; + durationMs: number; +} + +function run(cmd: string[], cwd: string): { exitCode: number; output: string } { + try { + const out = execFileSync(cmd[0], cmd.slice(1), { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 1024 * 1024, + }); + return { exitCode: 0, output: out.trim() }; + } catch (e: any) { + return { exitCode: e.status ?? 1, output: ((e.stdout ?? "") + (e.stderr ?? "")).trim() }; + } +} + +function parseTestOutput(output: string): { passed: number; failed: number; total: number } { + const p = output.match(/(\d+)\s+passed/); + const f = output.match(/(\d+)\s+failed/); + return { + passed: p ? parseInt(p[1]) : 0, + failed: f ? parseInt(f[1]) : 0, + total: (p ? parseInt(p[1]) : 0) + (f ? parseInt(f[1]) : 0), + }; +} + +export function runVerifyGate(spec: VerifyGateSpec): VerifyGateResult { + const start = Date.now(); + if (!fs.existsSync(path.join(spec.cwd, ".git"))) { + return { + build: { pass: false, exitCode: -1, output: "" }, + test: { pass: false, exitCode: -1, output: "", passed: 0, failed: 0, total: 0 }, + verdict: "fail", + durationMs: Date.now() - start, + }; + } + const buildResult = run(spec.buildCmd, spec.cwd); + const build = { pass: buildResult.exitCode === 0, exitCode: buildResult.exitCode, output: buildResult.output }; + if (build.exitCode !== 0) { + return { + build, + test: { pass: false, exitCode: -1, output: "", passed: 0, failed: 0, total: 0 }, + verdict: "fail", + durationMs: Date.now() - start, + }; + } + let lint: { pass: boolean; exitCode: number; output: string } | undefined; + if (spec.lintCmd) { + const r = run(spec.lintCmd, spec.cwd); + lint = { pass: r.exitCode === 0, exitCode: r.exitCode, output: r.output }; + } + const testResult = run(spec.testCmd, spec.cwd); + const counts = parseTestOutput(testResult.output); + const testPass = testResult.exitCode === 0 && counts.failed === 0; + const test = { pass: testPass, exitCode: testResult.exitCode, output: testResult.output, ...counts }; + return { + build, + lint, + test, + verdict: testPass ? "pass" : "fail", + durationMs: Date.now() - start, + }; +} + +export function verifyAgentTask(spec: VerifyGateSpec): { + result: VerifyGateResult; + passed: boolean; + worktreeCleaned: boolean; +} { + const result = runVerifyGate(spec); + const passed = result.verdict === "pass"; + return { result, passed, worktreeCleaned: false }; +} + +export function verifyAndClean(spec: VerifyGateSpec, worktreeName: string, wm: WorktreeManager): { + result: VerifyGateResult; + passed: boolean; + worktreeCleaned: boolean; +} { + const result = runVerifyGate(spec); + const passed = result.verdict === "pass"; + if (!passed && wm) wm.remove(worktreeName); + return { result, passed, worktreeCleaned: !passed }; +}