@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.
- package/LICENSE +21 -0
- package/README.md +777 -0
- package/docs/ADAPTER-CONTRACT.md +1260 -0
- package/docs/ARCHITECTURE.md +846 -0
- package/docs/CLI-QUICKREF.md +827 -0
- package/docs/COMPETITIVE-ANALYSIS.md +307 -0
- package/docs/INSTALL.md +403 -0
- package/docs/INTEGRATION-CLAUDE.md +126 -0
- package/docs/INTEGRATION-MCP.md +196 -0
- package/docs/INTEGRATION-OPENCODE.md +136 -0
- package/docs/INTEGRATION-PI.md +195 -0
- package/package.json +77 -0
- package/scripts/build-site.sh +39 -0
- package/scripts/doctor.sh +218 -0
- package/scripts/gen-cli-quickref.ts +232 -0
- package/scripts/install-cli.sh +60 -0
- package/scripts/install.sh +466 -0
- package/scripts/roadmap-lint.ts +200 -0
- package/scripts/uninstall.sh +202 -0
- package/src/cli-node.cjs +51 -0
- package/src/cli.ts +209 -0
- package/src/commands/ast.ts +255 -0
- package/src/commands/diff.ts +98 -0
- package/src/commands/edit.ts +93 -0
- package/src/commands/hash.ts +64 -0
- package/src/commands/intent.ts +68 -0
- package/src/commands/maintenance.ts +191 -0
- package/src/commands/mcp.ts +28 -0
- package/src/commands/provenance.ts +111 -0
- package/src/commands/read.ts +117 -0
- package/src/commands/route.ts +42 -0
- package/src/commands/shared.ts +65 -0
- package/src/commands/telemetry.ts +126 -0
- package/src/commands/verify.ts +61 -0
- package/src/core/ast-edit.ts +2357 -0
- package/src/core/batch-edit.ts +185 -0
- package/src/core/config.ts +189 -0
- package/src/core/diff-engine.ts +474 -0
- package/src/core/doctor.ts +303 -0
- package/src/core/encoding.ts +116 -0
- package/src/core/envelope.ts +163 -0
- package/src/core/exit-codes.ts +198 -0
- package/src/core/format.ts +339 -0
- package/src/core/grep.ts +180 -0
- package/src/core/hash-edit.ts +416 -0
- package/src/core/index.ts +155 -0
- package/src/core/intent.ts +584 -0
- package/src/core/locking.ts +292 -0
- package/src/core/module-system.ts +142 -0
- package/src/core/operations.ts +557 -0
- package/src/core/output.ts +122 -0
- package/src/core/path-normalize.ts +61 -0
- package/src/core/paths.ts +326 -0
- package/src/core/plan-executor.ts +437 -0
- package/src/core/platform.ts +132 -0
- package/src/core/provenance.ts +214 -0
- package/src/core/read.ts +111 -0
- package/src/core/redact.ts +98 -0
- package/src/core/resolve-content.ts +12 -0
- package/src/core/router.ts +463 -0
- package/src/core/snapshot.ts +346 -0
- package/src/core/telemetry.ts +838 -0
- package/src/core/utils.ts +7 -0
- package/src/core/verify-baseline.ts +186 -0
- package/src/core/verify-scope.ts +282 -0
- package/src/core/verify.ts +753 -0
- package/src/mcp/server.ts +325 -0
- package/templates/claude-section.md +12 -0
- package/templates/opencode-agent.md +106 -0
- package/templates/opencode-skill.md +241 -0
- package/templates/pi-extension.ts +288 -0
- package/templates/pi-skill.md +123 -0
- 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
|
+
}
|