Skip to content

Commit ffeb65c

Browse files
that-github-userunknownclaude
authored
Add pre-flight validation before spawning agents (#91)
Check git repo accessibility and test command safety BEFORE creating worktrees. preflightValidation() returns actionable error messages. 7 new tests covering git check, valid/invalid test commands. Generated by thinktank Opus (5 agents, all pass, Agent #5 recommended). Closes #54 Co-authored-by: unknown <that-github-user@github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e7f7db3 commit ffeb65c

File tree

2 files changed

+88
-3
lines changed

2 files changed

+88
-3
lines changed

src/commands/run.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,61 @@
11
import assert from "node:assert/strict";
22
import { describe, it } from "node:test";
3-
import { makeResultFilename } from "./run.js";
3+
import type { RunOptions } from "../types.js";
4+
import { makeResultFilename, preflightValidation } from "./run.js";
5+
6+
function makeOpts(overrides: Partial<RunOptions> = {}): RunOptions {
7+
return {
8+
prompt: "fix the bug",
9+
attempts: 3,
10+
timeout: 300,
11+
model: "sonnet",
12+
verbose: false,
13+
...overrides,
14+
};
15+
}
16+
17+
describe("preflightValidation", () => {
18+
it("passes in a valid git repo with no test command", async () => {
19+
const result = await preflightValidation(makeOpts());
20+
assert.equal(result, null);
21+
});
22+
23+
it("passes with a valid test command", async () => {
24+
const result = await preflightValidation(makeOpts({ testCmd: "npm test" }));
25+
assert.equal(result, null);
26+
});
27+
28+
it("rejects test command with shell operators", async () => {
29+
const result = await preflightValidation(makeOpts({ testCmd: "npm test && echo done" }));
30+
assert.ok(result);
31+
assert.ok(result.includes("Invalid --test-cmd"));
32+
assert.ok(result.includes("shell operators"));
33+
});
34+
35+
it("rejects test command with pipes", async () => {
36+
const result = await preflightValidation(makeOpts({ testCmd: "npm test | tee out.log" }));
37+
assert.ok(result);
38+
assert.ok(result.includes("Invalid --test-cmd"));
39+
});
40+
41+
it("rejects empty test command", async () => {
42+
const result = await preflightValidation(makeOpts({ testCmd: " " }));
43+
assert.ok(result);
44+
assert.ok(result.includes("Invalid --test-cmd"));
45+
});
46+
47+
it("rejects test command with backticks", async () => {
48+
const result = await preflightValidation(makeOpts({ testCmd: "`rm -rf /`" }));
49+
assert.ok(result);
50+
assert.ok(result.includes("Invalid --test-cmd"));
51+
});
52+
53+
it("rejects test command with redirection", async () => {
54+
const result = await preflightValidation(makeOpts({ testCmd: "npm test > out.log" }));
55+
assert.ok(result);
56+
assert.ok(result.includes("Invalid --test-cmd"));
57+
});
58+
});
459

560
describe("makeResultFilename", () => {
661
it("produces no colons in filename", () => {

src/commands/run.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,33 @@ import { mkdir, writeFile } from "node:fs/promises";
22
import { join } from "node:path";
33
import { getDefaultRunner, getRunner } from "../runners/registry.js";
44
import { analyzeConvergence, recommend } from "../scoring/convergence.js";
5-
import { runTests } from "../scoring/test-runner.js";
5+
import { runTests, validateTestCommand } from "../scoring/test-runner.js";
66
import type { AgentResult, EnsembleResult, RunOptions } from "../types.js";
77
import { displayApplyInstructions, displayHeader, displayResults } from "../utils/display.js";
8-
import { cleanupBranches, createWorktree, removeWorktree } from "../utils/git.js";
8+
import { cleanupBranches, createWorktree, getRepoRoot, removeWorktree } from "../utils/git.js";
9+
10+
/**
11+
* Pre-flight validation before spawning agents.
12+
* Returns an error message if validation fails, or null if everything is OK.
13+
*/
14+
export async function preflightValidation(opts: RunOptions): Promise<string | null> {
15+
// Check: current directory is a git repo with an accessible working tree
16+
try {
17+
await getRepoRoot();
18+
} catch {
19+
return "Not a git repository. Run this command from inside a git repo.";
20+
}
21+
22+
// Check: test command is valid (if specified)
23+
if (opts.testCmd) {
24+
const testError = validateTestCommand(opts.testCmd);
25+
if (testError) {
26+
return `Invalid --test-cmd: ${testError}`;
27+
}
28+
}
29+
30+
return null;
31+
}
932

1033
export async function run(opts: RunOptions): Promise<void> {
1134
displayHeader(opts.prompt, opts.attempts, opts.model);
@@ -26,6 +49,13 @@ export async function run(opts: RunOptions): Promise<void> {
2649
process.exit(1);
2750
}
2851

52+
// Pre-flight validation
53+
const preflightError = await preflightValidation(opts);
54+
if (preflightError) {
55+
console.error(` ${preflightError}`);
56+
process.exit(1);
57+
}
58+
2959
// Clean up any leftover worktrees/branches from previous runs
3060
await cleanupBranches().catch(() => {});
3161

0 commit comments

Comments
 (0)