@davesheffer/hunch 1.45.2 → 1.47.0
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/dist/cli/index.js +290 -353
- package/dist/cli/invocation.d.ts +22 -13
- package/dist/cli/invocation.js +49 -29
- package/dist/cli/verify.d.ts +28 -0
- package/dist/cli/verify.js +265 -0
- package/dist/constitution/delta.js +2 -2
- package/dist/constitution/experiment.d.ts +1 -1
- package/dist/constitution/sourceMutation.js +28 -18
- package/dist/core/agenthook.js +26 -6
- package/dist/core/boundedSpawn.d.ts +57 -0
- package/dist/core/boundedSpawn.js +321 -0
- package/dist/core/commitrepair.d.ts +43 -138
- package/dist/core/commitrepair.js +76 -167
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +1 -0
- package/dist/core/correction.js +9 -9
- package/dist/core/delivery.d.ts +6 -1
- package/dist/core/delivery.js +71 -5
- package/dist/core/docanchors.js +9 -9
- package/dist/core/drift.js +22 -27
- package/dist/core/escalations.d.ts +16 -58
- package/dist/core/escalations.js +39 -121
- package/dist/core/glob.d.ts +7 -7
- package/dist/core/glob.js +12 -11
- package/dist/core/groundingLag.d.ts +2 -9
- package/dist/core/groundingLag.js +5 -11
- package/dist/core/groundingMerge.d.ts +2 -7
- package/dist/core/groundingMerge.js +35 -54
- package/dist/core/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- package/dist/core/paths.d.ts +17 -24
- package/dist/core/paths.js +37 -43
- package/dist/core/projectDna.js +11 -1
- package/dist/core/refrepair.d.ts +2 -1
- package/dist/core/refrepair.js +4 -4
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/repairqueue.d.ts +17 -34
- package/dist/core/repairqueue.js +48 -98
- package/dist/core/taskSelection.js +48 -6
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- package/dist/core/types.d.ts +3 -3
- package/dist/core/types.js +1 -1
- package/dist/extractors/diff.d.ts +4 -4
- package/dist/extractors/diff.js +25 -29
- package/dist/extractors/git.d.ts +8 -23
- package/dist/extractors/git.js +77 -95
- package/dist/extractors/helm.d.ts +10 -0
- package/dist/extractors/helm.js +43 -59
- package/dist/extractors/indexer.d.ts +6 -0
- package/dist/extractors/indexer.js +168 -184
- package/dist/extractors/k8sManifest.d.ts +23 -80
- package/dist/extractors/k8sManifest.js +319 -690
- package/dist/extractors/languages.d.ts +37 -37
- package/dist/extractors/languages.js +83 -54
- package/dist/extractors/nativeTreeSitter.d.ts +4 -4
- package/dist/extractors/nativeTreeSitter.js +17 -14
- package/dist/extractors/parse.d.ts +27 -13
- package/dist/extractors/parse.js +120 -56
- package/dist/extractors/workspaces.js +3 -1
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.js +3 -5
- package/dist/integrations/health.d.ts +6 -0
- package/dist/integrations/health.js +33 -12
- package/dist/integrations/hooks.d.ts +13 -16
- package/dist/integrations/hooks.js +31 -30
- package/dist/integrations/mergeDriver.js +18 -17
- package/dist/integrations/optout.d.ts +3 -0
- package/dist/integrations/optout.js +17 -0
- package/dist/integrations/providers.d.ts +6 -2
- package/dist/integrations/providers.js +19 -2
- package/dist/integrations/team.js +4 -2
- package/dist/integrations/uninstall.d.ts +10 -4
- package/dist/integrations/uninstall.js +22 -18
- package/dist/mcp/server.d.ts +4 -5
- package/dist/mcp/server.js +107 -81
- package/dist/serve/app.js +9 -1
- package/dist/serve/config.js +5 -0
- package/dist/serve/writelock.d.ts +3 -1
- package/dist/serve/writelock.js +17 -3
- package/dist/store/hunchStore.d.ts +2 -1
- package/dist/store/hunchStore.js +20 -23
- package/dist/store/jsonStore.d.ts +13 -3
- package/dist/store/jsonStore.js +37 -13
- package/dist/store/stateBinding.js +17 -4
- package/dist/synthesis/provider.d.ts +19 -21
- package/dist/synthesis/provider.js +93 -154
- package/dist/synthesis/synthesize.d.ts +8 -8
- package/dist/synthesis/synthesize.js +33 -50
- package/package.json +3 -3
- package/server.json +2 -2
package/dist/core/agenthook.js
CHANGED
|
@@ -209,16 +209,32 @@ function explicitToolOutcome(response) {
|
|
|
209
209
|
return "success";
|
|
210
210
|
return "unknown";
|
|
211
211
|
}
|
|
212
|
-
|
|
212
|
+
/** Cursor's postToolUse carries `tool_output` as a JSON-stringified result
|
|
213
|
+
* payload, e.g. `{"exitCode":0,"stdout":"..."}` (cursor.com/docs/agent/hooks, #496).
|
|
214
|
+
* Parsed, its exit code reaches the probe gate; text that is not a JSON object
|
|
215
|
+
* stays a bare string, which carries no status. */
|
|
216
|
+
function cursorToolOutput(value) {
|
|
217
|
+
if (typeof value !== "string")
|
|
218
|
+
return value;
|
|
219
|
+
try {
|
|
220
|
+
const parsed = JSON.parse(value);
|
|
221
|
+
return obj(parsed) ?? value;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return value;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function normalizeToolOutcome(input, event, provider) {
|
|
213
228
|
if (event !== "PostToolUse" && event !== "PostToolUseFailure")
|
|
214
229
|
return undefined;
|
|
215
|
-
const response = input.tool_response ?? input.toolResponse ?? input.tool_result ?? input.toolResult
|
|
230
|
+
const response = input.tool_response ?? input.toolResponse ?? input.tool_result ?? input.toolResult
|
|
231
|
+
?? (provider === "cursor" ? cursorToolOutput(input.tool_output) ?? input.output : undefined);
|
|
216
232
|
return {
|
|
217
233
|
// Claude Code splits successful and failed calls into separate lifecycle
|
|
218
234
|
// events. Providers without that split may expose an explicit result flag.
|
|
219
235
|
status: event === "PostToolUseFailure" ? "failure" : explicitToolOutcome(response),
|
|
220
236
|
output: event === "PostToolUseFailure"
|
|
221
|
-
? toolOutput(input.error ?? response)
|
|
237
|
+
? toolOutput(input.error ?? (provider === "cursor" ? input.error_message : undefined) ?? response)
|
|
222
238
|
: toolOutput(response),
|
|
223
239
|
};
|
|
224
240
|
}
|
|
@@ -331,9 +347,13 @@ export function normalizeHookEvent(raw, provider) {
|
|
|
331
347
|
const event = eventName(input.hook_event_name ?? input.hookEventName ?? input.event, provider);
|
|
332
348
|
if (!event)
|
|
333
349
|
return null;
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
350
|
+
// Cursor's before/afterShellExecution carry the command at top level and no
|
|
351
|
+
// tool name. afterShellExecution has no exit status (only `output`), so its
|
|
352
|
+
// outcome stays unknown; the installed postToolUse hook delivers the status.
|
|
353
|
+
const cursorShell = provider === "cursor" && input.tool_name === undefined && input.tool_input === undefined && typeof input.command === "string";
|
|
354
|
+
const rawToolName = cursorShell ? "Shell" : stringAt(input, "tool_name", "toolName");
|
|
355
|
+
const toolInput = normalizeToolInput(cursorShell ? { command: input.command } : input.tool_input ?? input.toolInput, provider === "codex" && /^(?:apply_patch|patch)$/i.test(rawToolName ?? ""));
|
|
356
|
+
const toolOutcome = normalizeToolOutcome(input, event, provider);
|
|
337
357
|
return {
|
|
338
358
|
hook_event_name: event,
|
|
339
359
|
session_id: stringAt(input, "session_id", "sessionId", "conversation_id", "conversationId"),
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export interface BoundedSpawnOptions {
|
|
2
|
+
cwd?: string;
|
|
3
|
+
env?: NodeJS.ProcessEnv;
|
|
4
|
+
/** Written to the child's stdin, which is then closed. Omitted = no stdin. */
|
|
5
|
+
input?: string;
|
|
6
|
+
/** Supervisor deadline. Omitted or 0 = no deadline. */
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
/** stdout bytes allowed before the tree is killed. Default 16 MB. */
|
|
9
|
+
maxBuffer?: number;
|
|
10
|
+
/** Launch through the platform shell (cmd.exe on Windows). `file` is then
|
|
11
|
+
* the whole command line and `args` should be empty. */
|
|
12
|
+
shell?: boolean;
|
|
13
|
+
/** POSIX: how long the tree gets after SIGTERM before SIGKILL. Default 1500. */
|
|
14
|
+
killGraceMs?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface BoundedSpawnResult {
|
|
17
|
+
stdout: string;
|
|
18
|
+
stderr: string;
|
|
19
|
+
/** Exit status, or null when the child was killed or never started. */
|
|
20
|
+
status: number | null;
|
|
21
|
+
/** Signal that ended the child: its own exit signal, or on timeout/overflow
|
|
22
|
+
* the last signal the supervisor delivered to the tree. */
|
|
23
|
+
signal: NodeJS.Signals | null;
|
|
24
|
+
/** The supervisor's deadline fired and the process tree was killed. */
|
|
25
|
+
timedOut: boolean;
|
|
26
|
+
/** stdout exceeded maxBuffer and the process tree was killed. */
|
|
27
|
+
overflowed: boolean;
|
|
28
|
+
/** Spawn failure (e.g. ENOENT), or the supervisor itself failing. */
|
|
29
|
+
error?: NodeJS.ErrnoException;
|
|
30
|
+
}
|
|
31
|
+
/** Test seam: replace the ps binary used for the POSIX tree snapshot
|
|
32
|
+
* (null restores "ps"). Not for production callers. */
|
|
33
|
+
export declare function __setPsFileForTests(file: string | null): void;
|
|
34
|
+
/** Async bounded run. Always resolves; inspect `timedOut` / `error` / `status`. */
|
|
35
|
+
export declare function boundedSpawn(file: string, args: string[], opts?: BoundedSpawnOptions): Promise<BoundedSpawnResult>;
|
|
36
|
+
export interface BoundedSpawnSyncOptions extends Omit<BoundedSpawnOptions, "timeoutMs"> {
|
|
37
|
+
/** Supervisor deadline (required: this helper exists to bound the call). */
|
|
38
|
+
timeout: number;
|
|
39
|
+
/** stdout: captured ("pipe", the default), discarded, or written straight to
|
|
40
|
+
* an open file descriptor. */
|
|
41
|
+
stdout?: "pipe" | "ignore" | number;
|
|
42
|
+
}
|
|
43
|
+
/** Synchronous bounded run, spawnSync-shaped: never throws. */
|
|
44
|
+
export declare function boundedSpawnSync(file: string, args: string[], opts: BoundedSpawnSyncOptions): BoundedSpawnResult;
|
|
45
|
+
/** Error thrown by boundedExecFileSync, shaped like execFileSync's: `code`
|
|
46
|
+
* ETIMEDOUT / ENOBUFS / ENOENT, or `status` + `signal` for a failed exit. */
|
|
47
|
+
export interface BoundedExecError extends Error {
|
|
48
|
+
code?: string;
|
|
49
|
+
status?: number | null;
|
|
50
|
+
signal?: NodeJS.Signals | null;
|
|
51
|
+
stdout: string;
|
|
52
|
+
stderr: string;
|
|
53
|
+
}
|
|
54
|
+
/** Synchronous bounded exec: returns stdout (utf8) on exit 0, throws a
|
|
55
|
+
* BoundedExecError otherwise. On timeout the error carries
|
|
56
|
+
* `code: "ETIMEDOUT"` and the signal actually delivered to the tree. */
|
|
57
|
+
export declare function boundedExecFileSync(file: string, args: string[], opts: BoundedSpawnSyncOptions): string;
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/** Run a child process under our own timer and kill its WHOLE tree on timeout
|
|
2
|
+
* (issues #44, #489, #498).
|
|
3
|
+
*
|
|
4
|
+
* Node's `timeout` option on spawn/execFile/execFileSync/spawnSync kills only
|
|
5
|
+
* the root process. On Windows that root is often a wrapper (cmd.exe for a
|
|
6
|
+
* `.cmd` shim, or Git for Windows' `cmd\git.exe`), so the real worker (the
|
|
7
|
+
* agent CLI, or `git.exe` / `git-remote-https.exe`) keeps running as an
|
|
8
|
+
* orphan. Running `taskkill /T` afterwards is too late: the root is gone, so
|
|
9
|
+
* the tree can no longer be reached by parent pid.
|
|
10
|
+
*
|
|
11
|
+
* Both forms here run the command under a small Node supervisor that owns the
|
|
12
|
+
* deadline and kills the tree while the root still exists:
|
|
13
|
+
* - Windows: `taskkill /pid <root> /T /F` runs to completion BEFORE the root
|
|
14
|
+
* is killed.
|
|
15
|
+
* - POSIX: the descendants are found by walking a `ps -A -o pid=,ppid=`
|
|
16
|
+
* snapshot (on Linux without ps, `/proc/<pid>/stat`) from the root, taken
|
|
17
|
+
* while the root is still alive. The tree gets
|
|
18
|
+
* SIGTERM first (git removes its ref/pack lock files on SIGTERM, not on
|
|
19
|
+
* SIGKILL), then SIGKILL for whatever is still alive after a short grace.
|
|
20
|
+
*
|
|
21
|
+
* The child is NOT detached: it stays in Hunch's process group, so a terminal
|
|
22
|
+
* Ctrl-C still reaches it directly. The supervisor's timer is always cleared,
|
|
23
|
+
* and a timeout is reported as a result (`timedOut`), never a hang.
|
|
24
|
+
*
|
|
25
|
+
* Cost: one extra Node startup (~30 ms) per call, so this is for network or
|
|
26
|
+
* long-running commands, not hot local git calls. */
|
|
27
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
28
|
+
const RESULT_SENTINEL = "\n__HUNCH_BOUNDED_SPAWN_RESULT__";
|
|
29
|
+
const DEFAULT_KILL_GRACE_MS = 1_500;
|
|
30
|
+
/** Plain-JS supervisor run as `node -e`. Reads a JSON spec on stdin, runs the
|
|
31
|
+
* command, forwards its stdout/stderr, and appends one sentinel line with the
|
|
32
|
+
* outcome to stderr. */
|
|
33
|
+
const SUPERVISOR = `
|
|
34
|
+
const { spawn, execFile } = require("node:child_process");
|
|
35
|
+
const SENTINEL = ${JSON.stringify(RESULT_SENTINEL)};
|
|
36
|
+
const win = process.platform === "win32";
|
|
37
|
+
let raw = "";
|
|
38
|
+
process.stdin.setEncoding("utf8");
|
|
39
|
+
process.stdin.on("data", (d) => { raw += d; });
|
|
40
|
+
process.stdin.on("end", () => {
|
|
41
|
+
const spec = JSON.parse(raw);
|
|
42
|
+
const result = { status: null, signal: null, timedOut: false, overflowed: false, killSignal: null, error: null };
|
|
43
|
+
let child = null, timer = null, finished = false, killing = false, bytes = 0, errBytes = 0;
|
|
44
|
+
const finish = () => {
|
|
45
|
+
if (finished) return;
|
|
46
|
+
finished = true;
|
|
47
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
48
|
+
if (child) {
|
|
49
|
+
if (child.stdout) child.stdout.destroy();
|
|
50
|
+
if (child.stderr) child.stderr.destroy();
|
|
51
|
+
child.unref();
|
|
52
|
+
}
|
|
53
|
+
process.stderr.write(SENTINEL + JSON.stringify(result) + "\\n");
|
|
54
|
+
};
|
|
55
|
+
const rootGone = () => child.exitCode !== null || child.signalCode !== null;
|
|
56
|
+
const alive = (pid) => {
|
|
57
|
+
if (pid === child.pid) return !rootGone();
|
|
58
|
+
try { process.kill(pid, 0); return true; } catch (e) { return false; }
|
|
59
|
+
};
|
|
60
|
+
// pid -> ppid for every process: ps, else (Linux without procps, e.g.
|
|
61
|
+
// busybox) /proc/*/stat, else an empty map (only the root gets signalled).
|
|
62
|
+
const snapshot = (cb) => {
|
|
63
|
+
execFile(spec.psFile || "ps", ["-A", "-o", "pid=,ppid="], { timeout: 2000, windowsHide: true }, (err, out) => {
|
|
64
|
+
const ppids = new Map();
|
|
65
|
+
if (!err) {
|
|
66
|
+
for (const line of String(out).split("\\n")) {
|
|
67
|
+
const f = line.trim().split(/\\s+/);
|
|
68
|
+
if (f.length >= 2) ppids.set(Number(f[0]), Number(f[1]));
|
|
69
|
+
}
|
|
70
|
+
return cb(ppids);
|
|
71
|
+
}
|
|
72
|
+
if (process.platform !== "linux") return cb(ppids);
|
|
73
|
+
try {
|
|
74
|
+
const fs = require("node:fs");
|
|
75
|
+
for (const d of fs.readdirSync("/proc")) {
|
|
76
|
+
if (!/^\\d+$/.test(d)) continue;
|
|
77
|
+
try {
|
|
78
|
+
// "pid (comm) state ppid ...": comm may hold spaces and ')', so
|
|
79
|
+
// parse past the LAST ')'.
|
|
80
|
+
const stat = fs.readFileSync("/proc/" + d + "/stat", "utf8");
|
|
81
|
+
const f = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
82
|
+
ppids.set(Number(d), Number(f[1]));
|
|
83
|
+
} catch (e) { /* exited mid-scan */ }
|
|
84
|
+
}
|
|
85
|
+
} catch (e) {}
|
|
86
|
+
cb(ppids);
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
// Every pid reachable from roots in a pid -> ppid snapshot (roots included).
|
|
90
|
+
const reach = (roots, ppids) => {
|
|
91
|
+
const kids = new Map();
|
|
92
|
+
for (const [pid, ppid] of ppids) {
|
|
93
|
+
if (!kids.has(ppid)) kids.set(ppid, []);
|
|
94
|
+
kids.get(ppid).push(pid);
|
|
95
|
+
}
|
|
96
|
+
const seen = new Set(roots), stack = roots.slice();
|
|
97
|
+
while (stack.length) {
|
|
98
|
+
for (const c of kids.get(stack.pop()) || []) {
|
|
99
|
+
if (c !== process.pid && !seen.has(c)) { seen.add(c); stack.push(c); }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return Array.from(seen);
|
|
103
|
+
};
|
|
104
|
+
const signalAll = (pids, sig) => {
|
|
105
|
+
// Descendants first, the root last: the root stays alive (and the tree
|
|
106
|
+
// reachable) for as long as possible.
|
|
107
|
+
for (const pid of pids) {
|
|
108
|
+
if (pid === child.pid) continue;
|
|
109
|
+
try { process.kill(pid, sig); } catch (e) {}
|
|
110
|
+
}
|
|
111
|
+
try { child.kill(sig); } catch (e) {}
|
|
112
|
+
};
|
|
113
|
+
const killTree = () => {
|
|
114
|
+
if (killing) return;
|
|
115
|
+
killing = true;
|
|
116
|
+
if (!child || !child.pid) return finish();
|
|
117
|
+
if (win) {
|
|
118
|
+
execFile("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true, timeout: 10000 }, () => {
|
|
119
|
+
try { child.kill("SIGKILL"); } catch (e) {}
|
|
120
|
+
result.killSignal = "SIGKILL";
|
|
121
|
+
finish();
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
snapshot((ppids) => {
|
|
126
|
+
const pids = reach([child.pid], ppids);
|
|
127
|
+
signalAll(pids, "SIGTERM");
|
|
128
|
+
result.killSignal = "SIGTERM";
|
|
129
|
+
const deadline = Date.now() + spec.killGraceMs;
|
|
130
|
+
const poll = () => {
|
|
131
|
+
const left = pids.filter(alive);
|
|
132
|
+
if (left.length === 0) return finish();
|
|
133
|
+
if (Date.now() < deadline) { setTimeout(poll, 50); return; }
|
|
134
|
+
// Re-walk from the survivors: anything they spawned during the grace.
|
|
135
|
+
snapshot((now) => {
|
|
136
|
+
const rest = reach(left, now);
|
|
137
|
+
for (const pid of rest) { try { process.kill(pid, "SIGKILL"); } catch (e) {} }
|
|
138
|
+
try { child.kill("SIGKILL"); } catch (e) {}
|
|
139
|
+
result.killSignal = "SIGKILL";
|
|
140
|
+
finish();
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
poll();
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
const hasInput = typeof spec.input === "string";
|
|
147
|
+
try {
|
|
148
|
+
child = spawn(spec.file, spec.args, {
|
|
149
|
+
cwd: spec.cwd || undefined,
|
|
150
|
+
env: spec.env || process.env,
|
|
151
|
+
stdio: [hasInput ? "pipe" : "ignore", spec.stdout, "pipe"],
|
|
152
|
+
shell: spec.shell === true,
|
|
153
|
+
windowsHide: true,
|
|
154
|
+
});
|
|
155
|
+
} catch (e) {
|
|
156
|
+
result.error = { code: e && e.code, message: String(e && e.message) };
|
|
157
|
+
return finish();
|
|
158
|
+
}
|
|
159
|
+
if (spec.timeoutMs > 0) {
|
|
160
|
+
timer = setTimeout(() => { timer = null; if (finished) return; result.timedOut = true; killTree(); }, spec.timeoutMs);
|
|
161
|
+
}
|
|
162
|
+
if (child.stdout) child.stdout.on("data", (c) => {
|
|
163
|
+
if (killing) return;
|
|
164
|
+
bytes += c.length;
|
|
165
|
+
if (bytes > spec.maxBuffer) {
|
|
166
|
+
result.overflowed = true;
|
|
167
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
168
|
+
return killTree();
|
|
169
|
+
}
|
|
170
|
+
process.stdout.write(c);
|
|
171
|
+
});
|
|
172
|
+
child.stderr.on("data", (c) => {
|
|
173
|
+
if (killing || errBytes > spec.maxBuffer) return;
|
|
174
|
+
errBytes += c.length;
|
|
175
|
+
process.stderr.write(c);
|
|
176
|
+
});
|
|
177
|
+
child.on("error", (e) => { if (killing) return; result.error = { code: e && e.code, message: String(e && e.message) }; finish(); });
|
|
178
|
+
child.on("close", (code, signal) => {
|
|
179
|
+
if (killing) return;
|
|
180
|
+
result.status = code;
|
|
181
|
+
result.signal = signal;
|
|
182
|
+
finish();
|
|
183
|
+
});
|
|
184
|
+
if (hasInput) {
|
|
185
|
+
child.stdin.on("error", () => {});
|
|
186
|
+
child.stdin.end(spec.input);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
`;
|
|
190
|
+
let psFileOverride = null;
|
|
191
|
+
/** Test seam: replace the ps binary used for the POSIX tree snapshot
|
|
192
|
+
* (null restores "ps"). Not for production callers. */
|
|
193
|
+
export function __setPsFileForTests(file) {
|
|
194
|
+
psFileOverride = file;
|
|
195
|
+
}
|
|
196
|
+
function supervisorRequest(file, args, opts) {
|
|
197
|
+
const maxBuffer = opts.maxBuffer ?? 16 * 1024 * 1024;
|
|
198
|
+
const spec = JSON.stringify({
|
|
199
|
+
file, args,
|
|
200
|
+
cwd: opts.cwd ?? null,
|
|
201
|
+
env: opts.env ?? process.env,
|
|
202
|
+
input: opts.input ?? null,
|
|
203
|
+
timeoutMs: opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : 0,
|
|
204
|
+
killGraceMs: opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS,
|
|
205
|
+
maxBuffer,
|
|
206
|
+
shell: opts.shell === true,
|
|
207
|
+
stdout: opts.stdout ?? "pipe",
|
|
208
|
+
psFile: psFileOverride,
|
|
209
|
+
});
|
|
210
|
+
// The supervisor itself must not inherit user NODE_OPTIONS (loaders, --inspect).
|
|
211
|
+
const env = { ...process.env, ELECTRON_RUN_AS_NODE: "1" };
|
|
212
|
+
delete env.NODE_OPTIONS;
|
|
213
|
+
// Backstop only, if the supervisor itself wedges: its own timer is the real bound.
|
|
214
|
+
const backstopMs = opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs + (opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS) + 15_000 : undefined;
|
|
215
|
+
return { spec, env, maxBuffer, backstopMs };
|
|
216
|
+
}
|
|
217
|
+
function toResult(stdout, rawErr, failure) {
|
|
218
|
+
const at = rawErr.lastIndexOf(RESULT_SENTINEL);
|
|
219
|
+
const stderr = at >= 0 ? rawErr.slice(0, at) : rawErr;
|
|
220
|
+
let outcome = null;
|
|
221
|
+
if (at >= 0) {
|
|
222
|
+
try {
|
|
223
|
+
outcome = JSON.parse(rawErr.slice(at + RESULT_SENTINEL.length).trim());
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
outcome = null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const result = { stdout, stderr, status: null, signal: null, timedOut: false, overflowed: false };
|
|
230
|
+
if (!outcome) {
|
|
231
|
+
const error = new Error(`bounded spawn supervisor ${failure.error ? `failed: ${failure.error.message}` : `exited ${failure.status ?? failure.signal} without a result`}`);
|
|
232
|
+
error.code = failure.error?.code ?? "ESUPERVISOR";
|
|
233
|
+
result.error = error;
|
|
234
|
+
result.signal = failure.signal;
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
if (outcome.error) {
|
|
238
|
+
const error = new Error(outcome.error.message);
|
|
239
|
+
if (outcome.error.code)
|
|
240
|
+
error.code = outcome.error.code;
|
|
241
|
+
result.error = error;
|
|
242
|
+
}
|
|
243
|
+
result.timedOut = outcome.timedOut;
|
|
244
|
+
result.overflowed = outcome.overflowed;
|
|
245
|
+
result.status = outcome.status;
|
|
246
|
+
result.signal = outcome.killSignal ?? outcome.signal;
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
/** Async bounded run. Always resolves; inspect `timedOut` / `error` / `status`. */
|
|
250
|
+
export function boundedSpawn(file, args, opts = {}) {
|
|
251
|
+
const { spec, env, maxBuffer, backstopMs } = supervisorRequest(file, args, opts);
|
|
252
|
+
return new Promise((resolve) => {
|
|
253
|
+
const out = [];
|
|
254
|
+
const err = [];
|
|
255
|
+
let outLen = 0;
|
|
256
|
+
let backstop = null;
|
|
257
|
+
let settled = false;
|
|
258
|
+
const supervisor = spawn(process.execPath, ["-e", SUPERVISOR], { env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
259
|
+
const settle = (failure) => {
|
|
260
|
+
if (settled)
|
|
261
|
+
return;
|
|
262
|
+
settled = true;
|
|
263
|
+
if (backstop) {
|
|
264
|
+
clearTimeout(backstop);
|
|
265
|
+
backstop = null;
|
|
266
|
+
}
|
|
267
|
+
resolve(toResult(Buffer.concat(out).toString("utf8"), Buffer.concat(err).toString("utf8"), failure));
|
|
268
|
+
};
|
|
269
|
+
if (backstopMs)
|
|
270
|
+
backstop = setTimeout(() => { backstop = null; supervisor.kill("SIGKILL"); }, backstopMs);
|
|
271
|
+
supervisor.stdout.on("data", (d) => { if (outLen <= maxBuffer) {
|
|
272
|
+
out.push(d);
|
|
273
|
+
outLen += d.length;
|
|
274
|
+
} });
|
|
275
|
+
supervisor.stderr.on("data", (d) => { err.push(d); });
|
|
276
|
+
supervisor.on("error", (error) => settle({ error, status: null, signal: null }));
|
|
277
|
+
supervisor.on("close", (status, signal) => settle({ status, signal }));
|
|
278
|
+
supervisor.stdin.on("error", () => { });
|
|
279
|
+
supervisor.stdin.end(spec);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
/** Synchronous bounded run, spawnSync-shaped: never throws. */
|
|
283
|
+
export function boundedSpawnSync(file, args, opts) {
|
|
284
|
+
const stdoutMode = opts.stdout ?? "pipe";
|
|
285
|
+
const { spec, env, maxBuffer, backstopMs } = supervisorRequest(file, args, {
|
|
286
|
+
...opts,
|
|
287
|
+
timeoutMs: opts.timeout,
|
|
288
|
+
maxBuffer: opts.maxBuffer ?? 64 * 1024 * 1024,
|
|
289
|
+
stdout: typeof stdoutMode === "number" ? "inherit" : stdoutMode,
|
|
290
|
+
});
|
|
291
|
+
const run = spawnSync(process.execPath, ["-e", SUPERVISOR], {
|
|
292
|
+
input: spec,
|
|
293
|
+
stdio: ["pipe", typeof stdoutMode === "number" ? stdoutMode : "pipe", "pipe"],
|
|
294
|
+
env,
|
|
295
|
+
windowsHide: true,
|
|
296
|
+
maxBuffer: maxBuffer * 2 + 1024 * 1024,
|
|
297
|
+
timeout: backstopMs,
|
|
298
|
+
});
|
|
299
|
+
return toResult(run.stdout ? run.stdout.toString("utf8") : "", run.stderr ? run.stderr.toString("utf8") : "", run);
|
|
300
|
+
}
|
|
301
|
+
/** Synchronous bounded exec: returns stdout (utf8) on exit 0, throws a
|
|
302
|
+
* BoundedExecError otherwise. On timeout the error carries
|
|
303
|
+
* `code: "ETIMEDOUT"` and the signal actually delivered to the tree. */
|
|
304
|
+
export function boundedExecFileSync(file, args, opts) {
|
|
305
|
+
const r = boundedSpawnSync(file, args, opts);
|
|
306
|
+
const fail = (message, fields) => {
|
|
307
|
+
const error = new Error(message);
|
|
308
|
+
Object.assign(error, { stdout: r.stdout, stderr: r.stderr, status: r.status, signal: r.signal }, fields);
|
|
309
|
+
throw error;
|
|
310
|
+
};
|
|
311
|
+
if (r.timedOut)
|
|
312
|
+
return fail(`${file} timed out after ${opts.timeout}ms`, { code: "ETIMEDOUT" });
|
|
313
|
+
if (r.overflowed)
|
|
314
|
+
return fail(`${file}: stdout exceeded maxBuffer`, { code: "ENOBUFS" });
|
|
315
|
+
if (r.error)
|
|
316
|
+
return fail(`${file}: ${r.error.message}`, { code: r.error.code ?? "ESPAWN" });
|
|
317
|
+
if (r.status !== 0)
|
|
318
|
+
return fail(`${file} exited ${r.status ?? r.signal}`, {});
|
|
319
|
+
return r.stdout;
|
|
320
|
+
}
|
|
321
|
+
//# sourceMappingURL=boundedSpawn.js.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Decision } from "./types.js";
|
|
2
2
|
import type { CommitCandidate, CommitRepairStatus } from "../extractors/git.js";
|
|
3
|
+
/** One proposed provenance fix: decision `id` cites `from`, which should cite `to`. */
|
|
3
4
|
export interface CommitRewrite {
|
|
4
5
|
id: string;
|
|
5
6
|
from: string;
|
|
@@ -8,14 +9,12 @@ export interface CommitRewrite {
|
|
|
8
9
|
/** Bind review to the ordered queue and which targets are currently resolvable.
|
|
9
10
|
* Queue order matters: duplicate decision ids use first-match selection. */
|
|
10
11
|
export declare function commitRepairReviewHash(queued: readonly CommitRewrite[], withheld?: ReadonlySet<CommitRewrite>): string;
|
|
11
|
-
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
* {id, from} is a NEW proposal — not a repeat of the one that was rejected —
|
|
18
|
-
* and surfaces normally. */
|
|
12
|
+
/**
|
|
13
|
+
* Tombstone for a rewrite a human explicitly rejected with `--drop`. The key is
|
|
14
|
+
* the full `{id, from, to}` triple, not just `{id, from}`: a later merge that
|
|
15
|
+
* proposes a DIFFERENT `to` for the same `{id, from}` is a new proposal and
|
|
16
|
+
* must surface normally.
|
|
17
|
+
*/
|
|
19
18
|
export interface DroppedRewrite {
|
|
20
19
|
id: string;
|
|
21
20
|
from: string;
|
|
@@ -23,12 +22,10 @@ export interface DroppedRewrite {
|
|
|
23
22
|
}
|
|
24
23
|
export interface CommitRepairPlan {
|
|
25
24
|
rewrites: CommitRewrite[];
|
|
25
|
+
/** Distinct decision ids the rewrites touch. */
|
|
26
26
|
records: string[];
|
|
27
27
|
}
|
|
28
|
-
/** Live decisions whose cited commit
|
|
29
|
-
* ancestor of the current tip. Only "orphaned" qualifies: a commit that
|
|
30
|
-
* doesn't resolve at all ("unresolvable") is out of repair scope, and a git
|
|
31
|
-
* failure ("unknown") must never be mistaken for repair-eligible. */
|
|
28
|
+
/** Live decisions whose cited commit is orphaned (unresolvable/unknown/current are excluded). */
|
|
32
29
|
export declare function orphanedCommitDecisions(decisions: readonly Decision[], status: (sha: string) => CommitRepairStatus): Decision[];
|
|
33
30
|
/** Match each orphaned decision to exactly one candidate. A candidate with the
|
|
34
31
|
* same `git patch-id --stable` as the orphaned commit (a rebase- or
|
|
@@ -39,139 +36,47 @@ export declare function orphanedCommitDecisions(decisions: readonly Decision[],
|
|
|
39
36
|
* patch-id never equals any one of them, but weak: shared files like
|
|
40
37
|
* CHANGELOG.md often have one wrong superset (#450). */
|
|
41
38
|
export declare function planCommitRepair(orphaned: readonly Decision[], candidates: readonly CommitCandidate[], patchIdOf?: (sha: string) => string | null): CommitRepairPlan;
|
|
42
|
-
/**
|
|
43
|
-
* names still exists, still cites the commit the entry expects to replace (a
|
|
44
|
-
* decision that already moved on would make repairDecisionCommit's own
|
|
45
|
-
* stale-plan bail refuse it), and hasn't since been superseded/rejected —
|
|
46
|
-
* same exclusion as orphanedCommitDecisions, since repairing dead history's
|
|
47
|
-
* provenance isn't worth asking about. Backs the read-only escalation
|
|
48
|
-
* surface only (commitRepairEscalations) — absence-means-not-askable is
|
|
49
|
-
* safe there because it self-heals the moment visibility returns.
|
|
50
|
-
* repair-provenance itself does NOT use this: it reads the full store and
|
|
51
|
-
* may legitimately list (dry-run) or leave queued (apply) an entry this
|
|
52
|
-
* predicate would call not-live, so the escalation surface and the CLI can
|
|
53
|
-
* disagree about the same entry — see deadRewrites and resolvedRewriteIds
|
|
54
|
-
* for the CLI's own, looser-on-purpose reasoning. */
|
|
39
|
+
/** Queued entries that are still answerable: the decision exists, still cites `from`, and is live. */
|
|
55
40
|
export declare function liveRewrites(queued: readonly CommitRewrite[], decisions: readonly Decision[]): CommitRewrite[];
|
|
56
|
-
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
|
|
63
|
-
* docstring is why that matters — the queue is the one durable record of a
|
|
64
|
-
* match once ORIG_HEAD moves on and the matched-away commit can be gc'd, so
|
|
65
|
-
* deleting an entry on a merely-absent id would destroy it unrecoverably.
|
|
66
|
-
* Contrast liveRewrites, whose absence-means-not-askable rule is safe on
|
|
67
|
-
* the read-only escalation surface, which self-heals once visibility
|
|
68
|
-
* returns.
|
|
69
|
-
*
|
|
70
|
-
* Caveat: "demonstrably" is only as strong as this run's view of
|
|
71
|
-
* `decisions`, which comes from the same git-tracked, branch-dependent
|
|
72
|
-
* files that motivate treating ABSENCE as inconclusive. A decision whose
|
|
73
|
-
* `commit`/`status` genuinely differ between branches (e.g. a repair
|
|
74
|
-
* already applied and committed on one branch, checked out here from a
|
|
75
|
-
* branch that predates it) could still read as "moved on" when it hasn't,
|
|
76
|
-
* from this branch's perspective. Far narrower than the absence case this
|
|
77
|
-
* function exists to fix, and not addressed here. */
|
|
41
|
+
/**
|
|
42
|
+
* Queued entries whose decision IS visible but moved on (different or null
|
|
43
|
+
* commit) or was superseded/rejected. An id absent from `decisions` is never
|
|
44
|
+
* dead: absence comes from the reader's view (a branch checkout, an unmounted
|
|
45
|
+
* private overlay) and the CLI deletes whatever this returns. Caveat: a decision
|
|
46
|
+
* that differs between branches could still read as "moved on".
|
|
47
|
+
*/
|
|
78
48
|
export declare function deadRewrites(queued: readonly CommitRewrite[], decisions: readonly Decision[]): CommitRewrite[];
|
|
79
|
-
/**
|
|
80
|
-
* on), the ids repair-provenance may treat as RESOLVED once it's done: the
|
|
81
|
-
* decision is present in `decisions` this run, whether or not
|
|
82
|
-
* repairDecisionCommit actually changed it (a present decision that already
|
|
83
|
-
* moved on is "resolved" too — repairDecisionCommit's own bail refused it,
|
|
84
|
-
* and there is nothing more this run can do about it). An id whose decision
|
|
85
|
-
* is NOT present is never resolved — same reasoning as deadRewrites:
|
|
86
|
-
* absence is the reader's transient view, not proof the match is settled,
|
|
87
|
-
* so it must stay queued for a run where the decision is visible again. */
|
|
49
|
+
/** Ids of `toApply` whose decision is visible, whether or not the rewrite changed it. */
|
|
88
50
|
export declare function resolvedRewriteIds(toApply: readonly CommitRewrite[], decisions: readonly Decision[]): Set<string>;
|
|
89
|
-
/**
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
* match sharing {id, from} but proposing a different `to` is unaffected: it
|
|
93
|
-
* is a different proposal, not the rejected one.
|
|
94
|
-
*
|
|
95
|
-
* Preserves object identity for every surviving entry (a plain filter, never
|
|
96
|
-
* a clone or a rebuild) — repair-provenance's own action relies on this to
|
|
97
|
-
* diff its swept-vs-active queue by reference (`queue.filter(r =>
|
|
98
|
-
* !active.includes(r))`) rather than re-spelling the {id, from, to} key a
|
|
99
|
-
* third time. Do not change this to normalize or reconstruct entries
|
|
100
|
-
* without updating that call site too. */
|
|
101
|
-
export declare function withoutDropped(rewrites: readonly CommitRewrite[], dropped: readonly DroppedRewrite[]): CommitRewrite[];
|
|
102
|
-
/** Append newly-dropped {id, from, to} triples to the tombstone list,
|
|
103
|
-
* deduped — same append-only, dedupe-by-key shape as mergeRewrites. */
|
|
51
|
+
/** Drop rewrites whose exact {id, from, to} triple is tombstoned. A plain filter: survivors keep identity. */
|
|
52
|
+
export declare function withoutDropped<T extends CommitRewrite>(rewrites: readonly T[], dropped: readonly DroppedRewrite[]): T[];
|
|
53
|
+
/** `existing` followed by the entries of `newly` whose exact triple is not already tombstoned. */
|
|
104
54
|
export declare function addDropped(newly: readonly DroppedRewrite[], existing: readonly DroppedRewrite[]): DroppedRewrite[];
|
|
105
|
-
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
|
|
110
|
-
*
|
|
111
|
-
* Overrides by object identity, targeting exactly the one queued entry
|
|
112
|
-
* firstFor (id) would pick — the same first-match rule --drop applies, and
|
|
113
|
-
* the same identity-not-id targeting the dead-entry prune applies (#53; the
|
|
114
|
-
* prune itself removes every provably-dead entry, not just a first match).
|
|
115
|
-
* A corrupted queue file, a hand edit, or a bug upstream could otherwise
|
|
116
|
-
* carry a SECOND entry sharing that id; overriding by id alone would
|
|
117
|
-
* silently destroy that still-queued sibling too, with no tombstone and no
|
|
118
|
-
* way to recover it, even though it was never the entry that would have
|
|
119
|
-
* been applied. Any such sibling is left queued and untouched.
|
|
120
|
-
*
|
|
121
|
-
* How a survivor resolves: the dead-entry prune (deadRewrites), once
|
|
122
|
-
* --apply has moved the decision past the sibling's `from`; or a --drop
|
|
123
|
-
* whose tombstone matches the sibling's exact {id, from, to} — the same
|
|
124
|
-
* --drop that dropped the fresh entry, when the sibling happens to share
|
|
125
|
-
* its triple (the drop's own re-sweep, withoutDropped, catches it too), a
|
|
126
|
-
* second explicit --drop otherwise. NOT a later fresh detection: by this
|
|
127
|
-
* same first-match rule it always overrides the fresh entry sitting ahead
|
|
128
|
-
* of it, never the sibling. */
|
|
55
|
+
/**
|
|
56
|
+
* Fresh detections first, then the queued entries they did not override. A fresh
|
|
57
|
+
* entry overrides only the FIRST queued entry with its id (see `firstFor`);
|
|
58
|
+
* later duplicate-id siblings survive untouched.
|
|
59
|
+
*/
|
|
129
60
|
export declare function mergeRewrites(fresh: readonly CommitRewrite[], queued: readonly CommitRewrite[]): CommitRewrite[];
|
|
130
|
-
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
*
|
|
137
|
-
* A corrupted queue file, a hand-edited entry, or a bug upstream could
|
|
138
|
-
* otherwise get a `to` that never resolves to a real commit written
|
|
139
|
-
* straight into a decision's `commit` field and provenance evidence, then
|
|
140
|
-
* auto-committed into shared team memory. `withheld` entries must stay
|
|
141
|
-
* queued (never deleted, never passed to repairDecisionCommit) — the queue
|
|
142
|
-
* file is the one durable record of the match, same reasoning as
|
|
143
|
-
* deadRewrites/resolvedRewriteIds.
|
|
144
|
-
*
|
|
145
|
-
* Both returned arrays preserve object identity (never clones or rebuilds
|
|
146
|
-
* an entry) — the CLI's queue sweep and its "still queued" reporting both
|
|
147
|
-
* key a `Set` off the exact objects in `withheld`, by reference, precisely
|
|
148
|
-
* because a corrupted queue file can carry two entries sharing an id (one
|
|
149
|
-
* resolvable, one not): an id alone can't tell them apart. Do not change
|
|
150
|
-
* this to normalize or reconstruct entries without updating those call
|
|
151
|
-
* sites too — same caveat withoutDropped's own docstring carries. */
|
|
152
|
-
export declare function withheldForUnresolvableTo(toApply: readonly CommitRewrite[], existing: Set<string> | null): {
|
|
61
|
+
/**
|
|
62
|
+
* Split entries by whether their proposed `to` resolves here. `existing === null`
|
|
63
|
+
* means the check could not run, so it fails open (everything applicable).
|
|
64
|
+
* Order and object references are preserved.
|
|
65
|
+
*/
|
|
66
|
+
export declare function withheldForUnresolvableTo(toApply: readonly CommitRewrite[], existing: ReadonlySet<string> | null): {
|
|
153
67
|
applicable: CommitRewrite[];
|
|
154
68
|
withheld: CommitRewrite[];
|
|
155
69
|
};
|
|
156
|
-
/** The
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
* withheldForUnresolvableTo's, and mergeRewrites' own docstrings). */
|
|
162
|
-
export declare function firstFor(entries: readonly CommitRewrite[], id: string): CommitRewrite | undefined;
|
|
163
|
-
/** pickRewrite is firstFor scoped to a plan's own rewrites (planCommitRepair
|
|
164
|
-
* itself never produces same-id duplicates, but a plan carrying them isn't
|
|
165
|
-
* ruled out here — see firstFor). repairDecisionCommit uses this to decide
|
|
166
|
-
* what to WRITE; a caller that needs to know which entry WOULD be picked
|
|
167
|
-
* without writing anything (the CLI's --apply summary and queue sweep)
|
|
168
|
-
* calls this same function, so the two can never independently drift on
|
|
169
|
-
* which entry "won". */
|
|
70
|
+
/** The single first-match rule for duplicate decision ids. */
|
|
71
|
+
export declare function firstFor<T extends {
|
|
72
|
+
id: string;
|
|
73
|
+
}>(entries: readonly T[], id: string): T | undefined;
|
|
74
|
+
/** The plan's rewrite for `id` (first match). */
|
|
170
75
|
export declare function pickRewrite(plan: CommitRepairPlan, id: string): CommitRewrite | undefined;
|
|
171
|
-
/**
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Pure: apply the plan's rewrite to a decision. Returns the same reference when
|
|
78
|
+
* there is no entry or the record moved on (`commit` no longer equals `from`);
|
|
79
|
+
* otherwise a copy with `commit` and the `commit:<from>` evidence moved to `to`
|
|
80
|
+
* (deduped if `to` was already cited).
|
|
81
|
+
*/
|
|
177
82
|
export declare function repairDecisionCommit(d: Decision, plan: CommitRepairPlan): Decision;
|