@bigknoxy/hashpilot 4.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +777 -0
  3. package/docs/ADAPTER-CONTRACT.md +1260 -0
  4. package/docs/ARCHITECTURE.md +846 -0
  5. package/docs/CLI-QUICKREF.md +827 -0
  6. package/docs/COMPETITIVE-ANALYSIS.md +307 -0
  7. package/docs/INSTALL.md +403 -0
  8. package/docs/INTEGRATION-CLAUDE.md +126 -0
  9. package/docs/INTEGRATION-MCP.md +196 -0
  10. package/docs/INTEGRATION-OPENCODE.md +136 -0
  11. package/docs/INTEGRATION-PI.md +195 -0
  12. package/package.json +77 -0
  13. package/scripts/build-site.sh +39 -0
  14. package/scripts/doctor.sh +218 -0
  15. package/scripts/gen-cli-quickref.ts +232 -0
  16. package/scripts/install-cli.sh +60 -0
  17. package/scripts/install.sh +466 -0
  18. package/scripts/roadmap-lint.ts +200 -0
  19. package/scripts/uninstall.sh +202 -0
  20. package/src/cli-node.cjs +51 -0
  21. package/src/cli.ts +209 -0
  22. package/src/commands/ast.ts +255 -0
  23. package/src/commands/diff.ts +98 -0
  24. package/src/commands/edit.ts +93 -0
  25. package/src/commands/hash.ts +64 -0
  26. package/src/commands/intent.ts +68 -0
  27. package/src/commands/maintenance.ts +191 -0
  28. package/src/commands/mcp.ts +28 -0
  29. package/src/commands/provenance.ts +111 -0
  30. package/src/commands/read.ts +117 -0
  31. package/src/commands/route.ts +42 -0
  32. package/src/commands/shared.ts +65 -0
  33. package/src/commands/telemetry.ts +126 -0
  34. package/src/commands/verify.ts +61 -0
  35. package/src/core/ast-edit.ts +2357 -0
  36. package/src/core/batch-edit.ts +185 -0
  37. package/src/core/config.ts +189 -0
  38. package/src/core/diff-engine.ts +474 -0
  39. package/src/core/doctor.ts +303 -0
  40. package/src/core/encoding.ts +116 -0
  41. package/src/core/envelope.ts +163 -0
  42. package/src/core/exit-codes.ts +198 -0
  43. package/src/core/format.ts +339 -0
  44. package/src/core/grep.ts +180 -0
  45. package/src/core/hash-edit.ts +416 -0
  46. package/src/core/index.ts +155 -0
  47. package/src/core/intent.ts +584 -0
  48. package/src/core/locking.ts +292 -0
  49. package/src/core/module-system.ts +142 -0
  50. package/src/core/operations.ts +557 -0
  51. package/src/core/output.ts +122 -0
  52. package/src/core/path-normalize.ts +61 -0
  53. package/src/core/paths.ts +326 -0
  54. package/src/core/plan-executor.ts +437 -0
  55. package/src/core/platform.ts +132 -0
  56. package/src/core/provenance.ts +214 -0
  57. package/src/core/read.ts +111 -0
  58. package/src/core/redact.ts +98 -0
  59. package/src/core/resolve-content.ts +12 -0
  60. package/src/core/router.ts +463 -0
  61. package/src/core/snapshot.ts +346 -0
  62. package/src/core/telemetry.ts +838 -0
  63. package/src/core/utils.ts +7 -0
  64. package/src/core/verify-baseline.ts +186 -0
  65. package/src/core/verify-scope.ts +282 -0
  66. package/src/core/verify.ts +753 -0
  67. package/src/mcp/server.ts +325 -0
  68. package/templates/claude-section.md +12 -0
  69. package/templates/opencode-agent.md +106 -0
  70. package/templates/opencode-skill.md +241 -0
  71. package/templates/pi-extension.ts +288 -0
  72. package/templates/pi-skill.md +123 -0
  73. package/tsconfig.json +19 -0
@@ -0,0 +1,753 @@
1
+ import { realpathSync } from "fs";
2
+ import { join, dirname, resolve } from "path";
3
+ import { computeHash } from "./read";
4
+ // Aliased: this module already has its own async `findProjectRoot` that walks up
5
+ // looking for tool config files. That one answers "which tools apply here"; this
6
+ // one answers "where is the write boundary" and must not be confused with it.
7
+ import { safeWrite, findProjectRoot as findWriteRoot } from "./paths";
8
+ import { recordEvent, ErrorCode } from "./telemetry";
9
+ import { buildTestInvocation, parseFailures, type TestInvocation } from "./verify-scope";
10
+ import { addWarning } from "./envelope";
11
+ import {
12
+ compareToBaseline,
13
+ currentCommit,
14
+ readBaseline,
15
+ scopeSignature,
16
+ writeBaseline,
17
+ type Baseline,
18
+ type BaselineReport,
19
+ } from "./verify-baseline";
20
+
21
+ /** One tool invocation's outcome. */
22
+ export interface ToolRun {
23
+ passed: boolean;
24
+ output: string;
25
+ resolved?: string;
26
+ /** The tool was killed at the timeout. Never a pass, and never a plain fail. */
27
+ timedOut?: boolean;
28
+ /** Captured output hit the cap and was cut; see MAX_CAPTURED_OUTPUT. */
29
+ truncated?: boolean;
30
+ }
31
+
32
+ export interface VerifyResult {
33
+ files: string[];
34
+ formatter?: ToolRun;
35
+ linter?: ToolRun;
36
+ tests?: ToolRun;
37
+ typecheck?: ToolRun;
38
+ /**
39
+ * `timeout` is deliberately its own state: a suite that ran out of time says
40
+ * nothing about the edit, so collapsing it into `fail` both misreports the
41
+ * change and (with --revert-on-failure) destroys it.
42
+ *
43
+ * `skipped` is the same argument one step earlier: every check is opt-in, so
44
+ * a caller that requested none used to get `pass` — a vacuous truth over an
45
+ * empty check set, indistinguishable from a fully green run on the one
46
+ * assertion this command exists to make (#106).
47
+ */
48
+ overall: "pass" | "fail" | "timeout" | "skipped";
49
+ /** Which checks timed out, when `overall` is "timeout". */
50
+ timedOut?: string[];
51
+ /**
52
+ * Which checks actually ran. Empty means nothing was verified — see
53
+ * `overall: "skipped"`. Present on every result so a caller never has to
54
+ * infer coverage from which optional keys happen to be absent (#106).
55
+ */
56
+ checksRun: string[];
57
+ /** Set on any non-pass outcome so callers get a stable exit code. */
58
+ errorCode?: string;
59
+ /** Human-readable reason, set alongside `errorCode` on a non-pass outcome. */
60
+ message?: string;
61
+ /** How the test command was scoped — `scoped: false` means the full suite ran. */
62
+ testScope?: TestInvocation;
63
+ /** Baseline comparison, when one was requested. */
64
+ baseline?: BaselineReport;
65
+ elapsed_ms: number;
66
+ fileHashes: Record<string, string>;
67
+ detected?: {
68
+ formatter?: string;
69
+ linter?: string;
70
+ typecheck?: string;
71
+ testRunner?: string;
72
+ };
73
+ revertedFiles?: string[];
74
+ }
75
+
76
+ export interface VerifyOptions {
77
+ formatter?: string;
78
+ linter?: string;
79
+ testFilter?: string;
80
+ formatterArgs?: string[];
81
+ linterArgs?: string[];
82
+ testArgs?: string[];
83
+ typecheck?: string;
84
+ testRunner?: string;
85
+ autoDetect?: boolean;
86
+ revertOnFailure?: boolean;
87
+ timeout?: number;
88
+ /** When true, allow any binary name. When false (default), only allowlisted tools are permitted. */
89
+ allowArbitraryTool?: boolean;
90
+ /**
91
+ * Restrict the test run to the changed files. Default true — the full suite
92
+ * is the fallback, not the norm. `VerifyResult.testScope` always reports
93
+ * which one actually happened.
94
+ */
95
+ scopeTests?: boolean;
96
+ /**
97
+ * Subtract the baseline recorded for this commit, so only *newly* failing
98
+ * tests fail verification. Off by default: without a baseline on disk it
99
+ * changes nothing, and turning it on silently would hide the fact that the
100
+ * caller never recorded one.
101
+ */
102
+ useBaseline?: boolean;
103
+ }
104
+
105
+ // ---- Security: allowlist of known-safe binaries (B19) ----
106
+ // Any binary not on this list requires --allow-arbitrary-tool.
107
+ // The allowlist is checked against the first token (the actual executable),
108
+ // not against arguments — e.g. "tsc" passes but "/tmp/evil.sh" does not.
109
+ const ALLOWED_BINARIES = new Set([
110
+ // JavaScript/TypeScript tooling
111
+ "prettier", "biome", "eslint", "tsc",
112
+ "bun", "node", "npx",
113
+ "vitest", "jest",
114
+ // Python
115
+ "python", "python3", "mypy", "ruff", "black", "pytest",
116
+ // Go
117
+ "go",
118
+ // Rust
119
+ "cargo", "rustfmt", "clippy", "rustc",
120
+ // Generic/unix tools (safe for formatting/linting)
121
+ "gofmt",
122
+ ]);
123
+
124
+ // Arguments that turn an allowlisted tool into an arbitrary-code interpreter.
125
+ // Matched against every argument, not just the first — `node --experimental-x -e`
126
+ // is the same hole as `node -e`.
127
+ const DENIED_ARGS: Record<string, RegExp> = {
128
+ node: /^(-e|--eval|-p|--print|--input-type)$/,
129
+ bun: /^(-e|--eval|--print|repl|exec|x)$/,
130
+ python: /^(-c|-m)$/,
131
+ python3: /^(-c|-m)$/,
132
+ npx: /^(-c|--call|-p|--package)$/,
133
+ go: /^(run|generate|install)$/,
134
+ cargo: /^(run|install)$/,
135
+ };
136
+
137
+ /** The one directory a path-form binary may live in: the project's own bin shims. */
138
+ function allowedBinDir(): string {
139
+ return join(findWriteRoot(), "node_modules", ".bin");
140
+ }
141
+
142
+ /** Resolve a command string to [binary, ...args]. Validates the binary against
143
+ * the allowlist unless `allowArbitrary` is true. Returns an error string instead.
144
+ */
145
+ function resolveCommand(cmd: string, allowArbitrary: boolean): { binary: string; args: string[] } | { error: string } {
146
+ const parts = cmd.trim().split(/\s+/).filter(Boolean);
147
+ const binary = parts[0];
148
+ if (!binary) return { error: `empty command` };
149
+ const args = parts.slice(1);
150
+ if (allowArbitrary) return { binary, args };
151
+
152
+ // A path is checked by where it actually points, not by its basename.
153
+ // Basename matching let `/tmp/evil/tsc` through: the name is on the allowlist
154
+ // while the file is anything the caller chose to drop there.
155
+ if (/[\\/]/.test(binary)) {
156
+ let real: string;
157
+ try {
158
+ real = realpathSync(binary);
159
+ } catch {
160
+ return { error: `binary "${binary}" could not be resolved` };
161
+ }
162
+ const binDir = allowedBinDir();
163
+ if (dirname(real) !== binDir && dirname(resolve(binary)) !== binDir) {
164
+ return { error: `binary "${binary}" is a path outside ${binDir}. Use --allow-arbitrary-tool to override.` };
165
+ }
166
+ return { binary: real, args };
167
+ }
168
+
169
+ if (!ALLOWED_BINARIES.has(binary)) {
170
+ return {
171
+ error: `binary "${binary}" is not in the allowlist. Use --allow-arbitrary-tool to override. Allowed: ${[...ALLOWED_BINARIES].sort().join(", ")}`,
172
+ };
173
+ }
174
+
175
+ // An allowlisted binary is only safe with the arguments it was allowlisted for.
176
+ // `node`, `python`, `go` and friends all have a flag that turns them into a
177
+ // general-purpose interpreter, which defeats the allowlist entirely.
178
+ const denied = DENIED_ARGS[binary];
179
+ if (denied) {
180
+ const bad = args.find((a) => denied.test(a));
181
+ if (bad) {
182
+ return { error: `argument "${bad}" is not permitted for "${binary}" — it executes arbitrary code. Use --allow-arbitrary-tool to override.` };
183
+ }
184
+ }
185
+
186
+ return { binary, args };
187
+ }
188
+
189
+ // Extension-based tool defaults (used when autoDetect finds no config files)
190
+ const EXT_TOOLS: Record<string, { typecheck?: string; test: string }> = {
191
+ ".ts": { typecheck: "tsc --noEmit", test: "bun test" },
192
+ ".tsx": { typecheck: "tsc --noEmit", test: "bun test" },
193
+ ".py": { typecheck: "mypy", test: "pytest" },
194
+ ".go": { typecheck: "go vet", test: "go test" },
195
+ ".rs": { typecheck: "cargo check", test: "cargo test" },
196
+ ".js": { test: "bun test" },
197
+ ".jsx": { test: "bun test" },
198
+ };
199
+
200
+ // Test runner command mapping — never uses `npx` without --no-install (B19).
201
+ // A missing package is an error instead of a network fetch.
202
+ const TEST_RUNNER_MAP: Record<string, string> = {
203
+ "bun test": "bun test",
204
+ "vitest": "npx --no-install vitest run",
205
+ "jest": "npx --no-install jest",
206
+ "pytest": "python -m pytest",
207
+ "go test": "go test ./...",
208
+ "cargo test": "cargo test",
209
+ };
210
+
211
+ // How each test runner accepts a name filter
212
+ function buildTestFilterArgs(runner: string, filter: string): string[] {
213
+ switch (runner) {
214
+ case "bun test": return [filter];
215
+ case "vitest": return ["--testNamePattern", filter];
216
+ case "jest": return ["--testNamePattern", filter];
217
+ case "pytest": return ["-k", filter];
218
+ case "go test": return ["-run", filter];
219
+ case "cargo test": return [filter];
220
+ default: return [filter];
221
+ }
222
+ }
223
+
224
+ // ---- Auto-detection scanners ----
225
+
226
+ type DetectedTools = { formatter?: string; linter?: string; typecheck?: string; testRunner?: string };
227
+
228
+ async function scanPackageJson(rootDir: string): Promise<DetectedTools> {
229
+ const tools: DetectedTools = {};
230
+ try {
231
+ const raw = await Bun.file(`${rootDir}/package.json`).text();
232
+ const pkg = JSON.parse(raw);
233
+ const deps = { ...pkg.devDependencies, ...pkg.dependencies };
234
+ if (deps.prettier) tools.formatter = "prettier --write";
235
+ if (deps.eslint) tools.linter = "eslint";
236
+ if (deps.vitest) tools.testRunner = "vitest";
237
+ else if (deps.jest) tools.testRunner = "jest";
238
+ if (deps.typescript) tools.typecheck = "tsc --noEmit";
239
+ if (deps["@biomejs/biome"]) {
240
+ if (!tools.formatter) tools.formatter = "biome format --write";
241
+ if (!tools.linter) tools.linter = "biome lint";
242
+ }
243
+ } catch {}
244
+ return tools;
245
+ }
246
+
247
+ async function scanPyprojectToml(rootDir: string): Promise<DetectedTools> {
248
+ const tools: DetectedTools = {};
249
+ try {
250
+ const raw = await Bun.file(`${rootDir}/pyproject.toml`).text();
251
+ if (/\[tool\.pytest\]/.test(raw)) tools.testRunner = "pytest";
252
+ if (/\[tool\.mypy\]/.test(raw)) tools.typecheck = "mypy";
253
+ if (/\[tool\.ruff\]/.test(raw)) {
254
+ if (!tools.linter) tools.linter = "ruff check";
255
+ }
256
+ } catch {}
257
+ return tools;
258
+ }
259
+
260
+ async function scanGoMod(rootDir: string): Promise<DetectedTools> {
261
+ const tools: DetectedTools = {};
262
+ try {
263
+ await Bun.file(`${rootDir}/go.mod`).text();
264
+ tools.typecheck = "go vet";
265
+ tools.testRunner = "go test";
266
+ } catch {}
267
+ return tools;
268
+ }
269
+
270
+ async function scanCargoToml(rootDir: string): Promise<DetectedTools> {
271
+ const tools: DetectedTools = {};
272
+ try {
273
+ await Bun.file(`${rootDir}/Cargo.toml`).text();
274
+ tools.formatter = "rustfmt --edition 2021";
275
+ tools.linter = "cargo clippy";
276
+ tools.typecheck = "cargo check";
277
+ tools.testRunner = "cargo test";
278
+ } catch {}
279
+ return tools;
280
+ }
281
+
282
+ const CONFIG_SCANNERS: Record<string, (rootDir: string) => Promise<DetectedTools>> = {
283
+ "package.json": scanPackageJson,
284
+ "pyproject.toml": scanPyprojectToml,
285
+ "go.mod": scanGoMod,
286
+ "Cargo.toml": scanCargoToml,
287
+ };
288
+
289
+ // Walk up from a directory looking for config files
290
+ async function findProjectRoot(fromDir: string): Promise<string> {
291
+ let dir = fromDir;
292
+ for (let i = 0; i < 10; i++) {
293
+ for (const fname of Object.keys(CONFIG_SCANNERS)) {
294
+ const f = Bun.file(`${dir}/${fname}`);
295
+ if (await f.exists()) return dir;
296
+ }
297
+ const parent = dir.split("/").slice(0, -1).join("/") || "/";
298
+ if (parent === dir) break;
299
+ dir = parent;
300
+ }
301
+ return fromDir;
302
+ }
303
+
304
+ async function detectTools(
305
+ files: string[],
306
+ options: VerifyOptions
307
+ ): Promise<{ detected: DetectedTools; effective: VerifyOptions }> {
308
+ if (!options.autoDetect) return { detected: {}, effective: options };
309
+
310
+ const detected: DetectedTools = {};
311
+ const rootDir = files.length > 0
312
+ ? await findProjectRoot(files[0].split("/").slice(0, -1).join("/") || ".")
313
+ : ".";
314
+
315
+ for (const [fname, scanner] of Object.entries(CONFIG_SCANNERS)) {
316
+ const exists = await Bun.file(`${rootDir}/${fname}`).exists();
317
+ if (exists) {
318
+ const tools = await scanner(rootDir);
319
+ if (tools.formatter) detected.formatter = tools.formatter;
320
+ if (tools.linter) detected.linter = tools.linter;
321
+ if (tools.typecheck) detected.typecheck = tools.typecheck;
322
+ if (tools.testRunner) detected.testRunner = tools.testRunner;
323
+ break; // Use first matching config file
324
+ }
325
+ }
326
+
327
+ // Extension-based fallback if no config file found
328
+ if (!detected.testRunner && files.length > 0) {
329
+ const exts = new Set(files.map((f) => {
330
+ const m = f.match(/\.([^.]+)$/);
331
+ return m ? `.${m[1]}` : "";
332
+ }));
333
+ for (const ext of exts) {
334
+ const defs = EXT_TOOLS[ext];
335
+ if (defs) {
336
+ if (!detected.testRunner) detected.testRunner = defs.test;
337
+ if (!detected.typecheck && defs.typecheck) detected.typecheck = defs.typecheck;
338
+ }
339
+ }
340
+ }
341
+
342
+ // Merge: explicit options win over detected
343
+ const effective = {
344
+ ...options,
345
+ formatter: options.formatter || detected.formatter,
346
+ linter: options.linter || detected.linter,
347
+ typecheck: options.typecheck || detected.typecheck,
348
+ testRunner: options.testRunner || detected.testRunner,
349
+ };
350
+
351
+ // Warn when auto-detect fills in a tool from the target repo (B19)
352
+ for (const key of ["formatter", "linter", "typecheck", "testRunner"] as const) {
353
+ if (!options[key] && detected[key]) {
354
+ console.error(`[verify-changes] auto-detected ${key} from project config: ${detected[key]}`);
355
+ }
356
+ }
357
+
358
+ return { detected, effective };
359
+ }
360
+
361
+ // ---- Process execution ----
362
+
363
+ /**
364
+ * Cap on captured output per stream. Beyond this we keep reading (so the child
365
+ * never blocks on a full pipe) but stop retaining, and append the marker below
366
+ * so a caller can tell truncation from a tool that simply said little.
367
+ */
368
+ const MAX_CAPTURED_OUTPUT = 256 * 1024;
369
+ const TRUNCATION_MARKER = "\n[hashpilot: output truncated at 256KB]";
370
+
371
+ /**
372
+ * Read a stream to completion, retaining at most `cap` bytes.
373
+ *
374
+ * Draining past the cap is the point: a child writing more than the pipe buffer
375
+ * holds blocks until someone reads, so a parent that stops reading and waits on
376
+ * exit deadlocks. Both streams are drained concurrently for the same reason —
377
+ * reading stdout to EOF first hangs on a child that fills stderr.
378
+ */
379
+ async function drainStream(
380
+ stream: ReadableStream<Uint8Array> | null | undefined,
381
+ cap: number
382
+ ): Promise<{ text: string; truncated: boolean }> {
383
+ if (!stream) return { text: "", truncated: false };
384
+ const decoder = new TextDecoder();
385
+ const reader = stream.getReader();
386
+ let text = "";
387
+ let truncated = false;
388
+ try {
389
+ while (true) {
390
+ const { done, value } = await reader.read();
391
+ if (done) break;
392
+ if (truncated) continue; // keep draining, stop retaining
393
+ text += decoder.decode(value, { stream: true });
394
+ if (text.length > cap) {
395
+ text = text.slice(0, cap);
396
+ truncated = true;
397
+ }
398
+ }
399
+ } catch {
400
+ // A killed child can tear the stream down mid-read; keep what we have.
401
+ }
402
+ return { text, truncated };
403
+ }
404
+
405
+ async function runTool(
406
+ cmd: string,
407
+ args: string[],
408
+ timeoutMs: number = 30000,
409
+ allowArbitrary: boolean = false
410
+ ): Promise<ToolRun> {
411
+ // Validate binary against allowlist (B19)
412
+ const resolved = resolveCommand(cmd, allowArbitrary);
413
+ if ("error" in resolved) {
414
+ return { passed: false, output: `security: ${resolved.error}` };
415
+ }
416
+
417
+ const { binary, args: builtinArgs } = resolved;
418
+ const allArgs = [...builtinArgs, ...args];
419
+ const resolvedLine = `${binary} ${allArgs.filter(a => a.trim()).join(" ")}`;
420
+
421
+ // Log the command that is about to execute (B19 requirement)
422
+ console.error(`[verify-changes] running: ${resolvedLine}`);
423
+
424
+ // `--allow-arbitrary-tool` is a real escape hatch, so say so when it is what
425
+ // let this command through. Otherwise the override is invisible in the log
426
+ // and an audit cannot tell a vetted tool from a bypassed one.
427
+ if (allowArbitrary && "error" in resolveCommand(cmd, false)) {
428
+ console.error(`[verify-changes] WARNING: running non-allowlisted command "${resolvedLine}" (--allow-arbitrary-tool)`);
429
+ }
430
+
431
+ let timedOut = false;
432
+ let timer: ReturnType<typeof setTimeout> | undefined;
433
+
434
+ try {
435
+ // Always spawn without shell (B19): argv array keeps shell metacharacters inert
436
+ const proc = Bun.spawn([binary, ...allArgs], {
437
+ stdout: "pipe",
438
+ stderr: "pipe",
439
+ });
440
+ timer = setTimeout(() => {
441
+ timedOut = true;
442
+ try { proc.kill(); } catch {}
443
+ }, timeoutMs);
444
+
445
+ const [out, err] = await Promise.all([
446
+ drainStream(proc.stdout as ReadableStream<Uint8Array>, MAX_CAPTURED_OUTPUT),
447
+ drainStream(proc.stderr as ReadableStream<Uint8Array>, MAX_CAPTURED_OUTPUT),
448
+ ]);
449
+ const exitCode = await proc.exited;
450
+ const truncated = out.truncated || err.truncated;
451
+ const output = (out.text + "\n" + err.text).trim() + (truncated ? TRUNCATION_MARKER : "");
452
+ return {
453
+ passed: !timedOut && exitCode === 0,
454
+ output: timedOut ? `${output}\n[hashpilot: killed after ${timeoutMs}ms timeout]`.trim() : output,
455
+ resolved: resolvedLine,
456
+ ...(timedOut ? { timedOut: true } : {}),
457
+ ...(truncated ? { truncated: true } : {}),
458
+ };
459
+ } catch (err: any) {
460
+ return {
461
+ passed: false,
462
+ output: `Failed to run ${cmd}: ${err.message}`,
463
+ resolved: resolvedLine,
464
+ };
465
+ } finally {
466
+ if (timer) clearTimeout(timer);
467
+ }
468
+ }
469
+
470
+ /**
471
+ * The full test selection: scoping plus anything the caller narrowed it with.
472
+ *
473
+ * Baselines are keyed off this, not off `invocation.args` alone. A `--test-filter`
474
+ * or extra `--test-args` changes *which tests ran*, and a baseline recorded over
475
+ * one selection says nothing about another — keying on the scoping args only
476
+ * would let a narrow run be subtracted from a wide one.
477
+ */
478
+ function selectionArgs(
479
+ runner: string,
480
+ invocation: TestInvocation,
481
+ options: VerifyOptions
482
+ ): string[] {
483
+ return [
484
+ ...invocation.args,
485
+ ...(options.testArgs || []),
486
+ ...(options.testFilter ? buildTestFilterArgs(runner, options.testFilter) : []),
487
+ ];
488
+ }
489
+
490
+ /**
491
+ * Failure list for a run, or null when it cannot be trusted.
492
+ *
493
+ * Truncated output is treated as unparseable: the tail we dropped is exactly
494
+ * where a runner prints its failure summary, so a partial list would look like
495
+ * "fewer failures" and let baseline subtraction pass a real regression.
496
+ */
497
+ function runFailures(runner: string, run: ToolRun): string[] | null {
498
+ return run.truncated ? null : parseFailures(runner, run.output);
499
+ }
500
+
501
+ /** Directory scoping and baseline keys are resolved against. */
502
+ async function testRootFor(files: string[]): Promise<string> {
503
+ if (files.length === 0) return ".";
504
+ return findProjectRoot(files[0].split("/").slice(0, -1).join("/") || ".");
505
+ }
506
+
507
+ const NO_CHECKS_MESSAGE =
508
+ "no checks ran: every check is opt-in and none was requested or detected, so nothing about these files was verified";
509
+
510
+ // ---- Main entry point ----
511
+
512
+ export async function verifyChanges(
513
+ files: string[],
514
+ options: VerifyOptions = {}
515
+ ): Promise<VerifyResult> {
516
+ const start = Date.now();
517
+ const fileHashes: Record<string, string> = {};
518
+ const timeout = options.timeout ?? 30000;
519
+
520
+ // Snapshot originals for revert-on-failure
521
+ const originals = new Map<string, string>();
522
+ if (options.revertOnFailure) {
523
+ for (const f of files) {
524
+ try { originals.set(f, await Bun.file(f).text()); } catch {}
525
+ }
526
+ }
527
+
528
+ for (const f of files) {
529
+ try {
530
+ const content = await Bun.file(f).text();
531
+ fileHashes[f] = computeHash(content);
532
+ } catch {
533
+ fileHashes[f] = "ERROR";
534
+ }
535
+ }
536
+
537
+ const { detected, effective } = await detectTools(files, options);
538
+ const allowArbitrary = options.allowArbitraryTool ?? false;
539
+
540
+ // Formatter
541
+ const formatter = effective.formatter ? await runTool(
542
+ effective.formatter,
543
+ [...(options.formatterArgs || []), ...files],
544
+ timeout,
545
+ allowArbitrary
546
+ ) : undefined;
547
+
548
+ // Linter
549
+ const linter = effective.linter ? await runTool(
550
+ effective.linter,
551
+ [...(options.linterArgs || []), ...files],
552
+ timeout,
553
+ allowArbitrary
554
+ ) : undefined;
555
+
556
+ // Typecheck
557
+ const typecheck = effective.typecheck ? await runTool(
558
+ effective.typecheck,
559
+ files,
560
+ timeout,
561
+ allowArbitrary
562
+ ) : undefined;
563
+
564
+ // Tests
565
+ let tests: ToolRun | undefined;
566
+ let testScope: TestInvocation | undefined;
567
+ let baselineReport: BaselineReport | undefined;
568
+ if (effective.testRunner || options.testFilter) {
569
+ const runner = effective.testRunner || "bun test";
570
+ const rootDir = await testRootFor(files);
571
+ const invocation = buildTestInvocation(runner, files, rootDir, { scope: options.scopeTests });
572
+ testScope = invocation;
573
+ const selection = selectionArgs(runner, invocation, options);
574
+ const testArgs = [
575
+ ...(runner === "pytest" ? ["-rf"] : []), // guarantees the parseable summary
576
+ ...selection,
577
+ ];
578
+ tests = await runTool(invocation.cmd, testArgs, timeout, allowArbitrary);
579
+
580
+ // Baseline subtraction. A timeout is never compared — it has no failure
581
+ // list, only an absence of information.
582
+ if (options.useBaseline && !tests.timedOut) {
583
+ const scopeKey = scopeSignature(selection);
584
+ const commit = await currentCommit(rootDir);
585
+ const baseline = commit ? await readBaseline(rootDir, commit, runner, scopeKey) : undefined;
586
+ baselineReport = commit
587
+ ? compareToBaseline(baseline, runner, scopeKey, runFailures(runner, tests))
588
+ : { source: "none", comparable: false, reason: "not a git repository; cannot key a baseline" };
589
+
590
+ // Only newly failing tests count. A run that fails purely on tests that
591
+ // were already broken at this commit is what makes --revert-on-failure
592
+ // safe to enable.
593
+ if (!tests.passed && baselineReport.comparable && baselineReport.newFailures?.length === 0) {
594
+ tests = { ...tests, passed: true };
595
+ }
596
+ }
597
+ }
598
+
599
+ const elapsed = Date.now() - start;
600
+
601
+ const allPass =
602
+ (!formatter || formatter.passed) &&
603
+ (!linter || linter.passed) &&
604
+ (!typecheck || typecheck.passed) &&
605
+ (!tests || tests.passed);
606
+
607
+ const failedIn: string[] = [];
608
+ if (formatter && !formatter.passed) failedIn.push("formatter");
609
+ if (linter && !linter.passed) failedIn.push("linter");
610
+ if (typecheck && !typecheck.passed) failedIn.push("typecheck");
611
+ if (tests && !tests.passed) failedIn.push("tests");
612
+
613
+ const timedOut = ([
614
+ ["formatter", formatter],
615
+ ["linter", linter],
616
+ ["typecheck", typecheck],
617
+ ["tests", tests],
618
+ ] as const).filter(([, run]) => run?.timedOut).map(([name]) => name);
619
+
620
+ const checksRun = ([
621
+ ["formatter", formatter],
622
+ ["linter", linter],
623
+ ["typecheck", typecheck],
624
+ ["tests", tests],
625
+ ] as const).filter(([, run]) => run !== undefined && run !== null).map(([name]) => name);
626
+
627
+ // Timeout outranks failure: a check that never finished has not judged the
628
+ // edit, and reporting "fail" would license a revert on no evidence.
629
+ // "skipped" outranks both — with no check at all there is no evidence to
630
+ // weigh, and `allPass` is vacuously true over the empty set (#106).
631
+ const overall: "pass" | "fail" | "timeout" | "skipped" =
632
+ checksRun.length === 0 ? "skipped" : timedOut.length > 0 ? "timeout" : allPass ? "pass" : "fail";
633
+
634
+ if (overall === "skipped") {
635
+ addWarning({
636
+ code: "VERIFY_NO_CHECKS",
637
+ message: NO_CHECKS_MESSAGE,
638
+ recovery: "Pass --auto-detect, or name a check explicitly with --formatter/--linter/--typecheck/--test-runner.",
639
+ });
640
+ }
641
+
642
+ const result: VerifyResult = {
643
+ files,
644
+ formatter: formatter || undefined,
645
+ linter: linter || undefined,
646
+ typecheck: typecheck || undefined,
647
+ tests: tests || undefined,
648
+ overall,
649
+ checksRun,
650
+ message: overall === "skipped" ? NO_CHECKS_MESSAGE : undefined,
651
+ timedOut: timedOut.length > 0 ? [...timedOut] : undefined,
652
+ errorCode:
653
+ overall === "skipped"
654
+ ? ErrorCode.VERIFY_NO_CHECKS
655
+ : overall === "timeout"
656
+ ? ErrorCode.VERIFY_TIMEOUT
657
+ : overall === "fail"
658
+ ? ErrorCode.VERIFY_FAILED
659
+ : undefined,
660
+ testScope,
661
+ baseline: baselineReport,
662
+ elapsed_ms: elapsed,
663
+ fileHashes,
664
+ detected: Object.keys(detected).length > 0 ? detected : undefined,
665
+ };
666
+
667
+ // Revert on failure. Deliberately not on `timeout` — deleting the caller's
668
+ // work because a suite was slow is the destructive half of issue #24.
669
+ if (overall === "fail" && options.revertOnFailure && originals.size > 0) {
670
+ const reverted: string[] = [];
671
+ for (const [f, original] of originals) {
672
+ try { await safeWrite(f, original); reverted.push(f); } catch {}
673
+ }
674
+ result.revertedFiles = reverted;
675
+ }
676
+
677
+ recordEvent({
678
+ operation: "verify-changes",
679
+ route: "verify",
680
+ success: overall === "pass",
681
+ verification_result: overall,
682
+ failed_in: failedIn.length > 0 ? failedIn : undefined,
683
+ elapsed_ms: elapsed,
684
+ files_count: files.length,
685
+ });
686
+
687
+ return result;
688
+ }
689
+
690
+ export interface RecordBaselineResult {
691
+ recorded: boolean;
692
+ reason: string;
693
+ commit?: string;
694
+ runner?: string;
695
+ failures?: string[] | null;
696
+ /** True when a usable baseline already existed for this commit and scope. */
697
+ cached?: boolean;
698
+ }
699
+
700
+ /**
701
+ * Record which tests already fail, for later subtraction by `useBaseline`.
702
+ *
703
+ * Must be called on the pre-edit tree — it runs the suite as-is and believes
704
+ * what it sees. `plan-executor` calls it before applying any step; a human or
705
+ * agent can call it directly via `verify-changes --record-baseline`.
706
+ *
707
+ * Cached per commit SHA (and per test selection), so the cost is paid once per
708
+ * commit rather than once per edit.
709
+ */
710
+ export async function recordVerifyBaseline(
711
+ files: string[],
712
+ options: VerifyOptions = {}
713
+ ): Promise<RecordBaselineResult> {
714
+ const { effective } = await detectTools(files, options);
715
+ const runner = effective.testRunner || options.testRunner;
716
+ if (!runner) return { recorded: false, reason: "no test runner configured or detected" };
717
+
718
+ const rootDir = await testRootFor(files);
719
+ const commit = await currentCommit(rootDir);
720
+ if (!commit) return { recorded: false, reason: "not a git repository; a baseline needs a commit to key on" };
721
+
722
+ const invocation = buildTestInvocation(runner, files, rootDir, { scope: options.scopeTests });
723
+ const selection = selectionArgs(runner, invocation, options);
724
+ const scopeKey = scopeSignature(selection);
725
+
726
+ const existing = await readBaseline(rootDir, commit, runner, scopeKey);
727
+ if (existing) {
728
+ return { recorded: false, cached: true, reason: "baseline already recorded for this commit", commit, runner, failures: existing.failures };
729
+ }
730
+
731
+ const run = await runTool(
732
+ invocation.cmd,
733
+ [...(runner === "pytest" ? ["-rf"] : []), ...selection],
734
+ options.timeout ?? 30000,
735
+ options.allowArbitraryTool ?? false
736
+ );
737
+ if (run.timedOut) {
738
+ // A timed-out baseline would record "nothing was failing", which would then
739
+ // mark every real pre-existing failure as new. Refuse to write it.
740
+ return { recorded: false, reason: "baseline run timed out; not recording an unreliable baseline", commit, runner };
741
+ }
742
+
743
+ const baseline: Baseline = {
744
+ commit,
745
+ runner,
746
+ scopeKey,
747
+ failures: runFailures(runner, run),
748
+ clean: run.passed,
749
+ recorded_at: new Date().toISOString(),
750
+ };
751
+ await writeBaseline(rootDir, baseline);
752
+ return { recorded: true, reason: "baseline recorded", commit, runner, failures: baseline.failures };
753
+ }