@davesheffer/hunch 1.7.0 → 1.7.1
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/README.md +214 -0
- package/bench/constitution-exp03-v1.json +70 -0
- package/dist/cli/index.js +1203 -24
- package/dist/constitution/adapters.js +487 -0
- package/dist/constitution/behaviorAttestationBinding.js +17 -0
- package/dist/constitution/behaviorEvaluator.js +220 -0
- package/dist/constitution/behaviorProof.js +205 -0
- package/dist/constitution/behaviorWorkspace.js +124 -0
- package/dist/constitution/bootstrap.js +133 -0
- package/dist/constitution/canonical.js +51 -0
- package/dist/constitution/card.js +133 -0
- package/dist/constitution/compiler.js +176 -0
- package/dist/constitution/composition.js +101 -0
- package/dist/constitution/corpus.js +58 -0
- package/dist/constitution/delta.js +154 -0
- package/dist/constitution/disposition.js +141 -0
- package/dist/constitution/evaluator.js +435 -0
- package/dist/constitution/experiment.js +948 -0
- package/dist/constitution/experimentRunner.js +344 -0
- package/dist/constitution/g2.js +291 -0
- package/dist/constitution/g2BehaviorAttestation.js +209 -0
- package/dist/constitution/g2BehaviorCandidates.js +703 -0
- package/dist/constitution/g2BehaviorDependencies.js +379 -0
- package/dist/constitution/g2BehaviorMaterialization.js +171 -0
- package/dist/constitution/g2BehaviorPolicyMaterializer.js +241 -0
- package/dist/constitution/g2CandidateAttestation.js +179 -0
- package/dist/constitution/g2Candidates.js +195 -0
- package/dist/constitution/g2Drills.js +122 -0
- package/dist/constitution/g3.js +511 -0
- package/dist/constitution/g3Conformance.js +115 -0
- package/dist/constitution/lifecycle.js +189 -0
- package/dist/constitution/mutation.js +262 -0
- package/dist/constitution/nodeTestEvidence.js +47 -0
- package/dist/constitution/plan.js +172 -0
- package/dist/constitution/policyRuntime.js +8 -0
- package/dist/constitution/proof.js +166 -0
- package/dist/constitution/replay.js +361 -0
- package/dist/constitution/replayCache.js +89 -0
- package/dist/constitution/replayWorker.js +34 -0
- package/dist/constitution/repository.js +533 -0
- package/dist/constitution/schema.js +545 -0
- package/dist/constitution/scorecard.js +106 -0
- package/dist/constitution/service.js +1149 -0
- package/dist/constitution/shadow.js +235 -0
- package/dist/constitution/sourceMutation.js +316 -0
- package/dist/constitution/structural.js +601 -0
- package/dist/core/autoreview.js +27 -3
- package/dist/core/dupdetect.js +10 -3
- package/dist/core/events.js +61 -0
- package/dist/core/externalImports.js +24 -0
- package/dist/core/hookpolicy.js +3 -0
- package/dist/core/relativeImports.js +33 -0
- package/dist/core/stats.js +115 -0
- package/dist/extractors/git.js +81 -0
- package/dist/extractors/indexer.js +39 -38
- package/dist/extractors/nativeTreeSitter.js +108 -0
- package/dist/extractors/parse.js +5 -15
- package/dist/integrations/claudemd.js +8 -1
- package/dist/integrations/gitignore.js +8 -0
- package/dist/integrations/providers.js +32 -10
- package/dist/integrations/sync.js +16 -1
- package/dist/mcp/server.js +284 -0
- package/package.json +5 -1
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { revExists } from "../extractors/git.js";
|
|
6
|
+
import { canonicalHash, proofEvaluationHash, proofPlanContentHash } from "./canonical.js";
|
|
7
|
+
import { assertCompositionBinding, policyProofHash } from "./composition.js";
|
|
8
|
+
import { evaluateCompositePolicyOnSnapshot, evaluatePolicyOnSnapshot } from "./evaluator.js";
|
|
9
|
+
import { loadReplaySnapshot, putReplaySnapshot } from "./replayCache.js";
|
|
10
|
+
import { POLICY_EVALUATOR, ProofPlanSchema, ReplayReceiptSchema, } from "./schema.js";
|
|
11
|
+
const ZERO_SHA = "0".repeat(40);
|
|
12
|
+
export const DEFAULT_REPLAY_WORKERS = 4;
|
|
13
|
+
export const MAX_REPLAY_WORKERS = 8;
|
|
14
|
+
const POLL_STATE = new Int32Array(new SharedArrayBuffer(4));
|
|
15
|
+
function stableCommit(ref) {
|
|
16
|
+
return /^[a-f0-9]{40}$/.test(ref) ? ref : ZERO_SHA;
|
|
17
|
+
}
|
|
18
|
+
export function replaySafeEnvironment(home, gitConfig) {
|
|
19
|
+
const env = {};
|
|
20
|
+
for (const key of ["PATH", "SystemRoot", "WINDIR", "TMPDIR", "TMP", "TEMP"]) {
|
|
21
|
+
if (process.env[key])
|
|
22
|
+
env[key] = process.env[key];
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
...env,
|
|
26
|
+
HOME: home,
|
|
27
|
+
GIT_CONFIG_GLOBAL: gitConfig,
|
|
28
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
29
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
30
|
+
GIT_LFS_SKIP_SMUDGE: "1",
|
|
31
|
+
HUNCH_PRIVATE_DIR: "",
|
|
32
|
+
HUNCH_SYNTH_PROVIDER: "deterministic",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function hasUnsafeReplayFilter(root, env) {
|
|
36
|
+
try {
|
|
37
|
+
const raw = execFileSync("git", ["-C", root, "config", "--local", "--name-only", "--get-regexp", "^filter\\."], {
|
|
38
|
+
env,
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
41
|
+
});
|
|
42
|
+
return raw.trim().split("\n").some((key) => key && !key.startsWith("filter.lfs."));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function replayGitArgs(root, hooks, args) {
|
|
49
|
+
return [
|
|
50
|
+
"-C", root,
|
|
51
|
+
"-c", `core.hooksPath=${hooks}`,
|
|
52
|
+
"-c", "core.fsmonitor=false",
|
|
53
|
+
"-c", "credential.helper=",
|
|
54
|
+
"-c", "filter.lfs.required=false",
|
|
55
|
+
"-c", "filter.lfs.smudge=",
|
|
56
|
+
"-c", "filter.lfs.process=",
|
|
57
|
+
...args,
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
function errorCode(error, fallback) {
|
|
61
|
+
const e = error;
|
|
62
|
+
return e.code === "ETIMEDOUT" || e.killed || e.signal === "SIGTERM" ? "timeout" : fallback;
|
|
63
|
+
}
|
|
64
|
+
function workerLimit(value) {
|
|
65
|
+
if (value == null || !Number.isFinite(value))
|
|
66
|
+
return DEFAULT_REPLAY_WORKERS;
|
|
67
|
+
return Math.max(1, Math.min(MAX_REPLAY_WORKERS, Math.trunc(value)));
|
|
68
|
+
}
|
|
69
|
+
function replayWorkerUrl() {
|
|
70
|
+
const source = new URL("./replayWorker.ts", import.meta.url);
|
|
71
|
+
return existsSync(fileURLToPath(source)) ? source : new URL("./replayWorker.js", import.meta.url);
|
|
72
|
+
}
|
|
73
|
+
function replayWorkerArgs(taskFile, resultFile) {
|
|
74
|
+
const worker = replayWorkerUrl();
|
|
75
|
+
return worker.pathname.endsWith(".ts")
|
|
76
|
+
? ["--import", import.meta.resolve("tsx"), fileURLToPath(worker), taskFile, resultFile]
|
|
77
|
+
: [fileURLToPath(worker), taskFile, resultFile];
|
|
78
|
+
}
|
|
79
|
+
export function cleanupReplayWorktree(root, hooks, env, run, checkout) {
|
|
80
|
+
let failed = false;
|
|
81
|
+
try {
|
|
82
|
+
execFileSync("git", replayGitArgs(root, hooks, ["worktree", "remove", "--force", checkout]), {
|
|
83
|
+
env,
|
|
84
|
+
timeout: 10_000,
|
|
85
|
+
stdio: "ignore",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
rmSync(checkout, { recursive: true, force: true });
|
|
90
|
+
try {
|
|
91
|
+
execFileSync("git", replayGitArgs(root, hooks, ["worktree", "remove", "--force", checkout]), {
|
|
92
|
+
env,
|
|
93
|
+
timeout: 10_000,
|
|
94
|
+
stdio: "ignore",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
failed = true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
rmSync(run, { recursive: true, force: true });
|
|
102
|
+
return failed;
|
|
103
|
+
}
|
|
104
|
+
function makeReceipt(leg, commit, policyHash, expected, outcome) {
|
|
105
|
+
const body = {
|
|
106
|
+
leg,
|
|
107
|
+
commit: stableCommit(commit),
|
|
108
|
+
...(expected ? { expected } : {}),
|
|
109
|
+
policy_hash: policyHash,
|
|
110
|
+
evaluator: { ...POLICY_EVALUATOR },
|
|
111
|
+
result: outcome.evaluation?.result ?? "error",
|
|
112
|
+
...(outcome.snapshot ? { graph_hash: outcome.snapshot.graph_hash } : {}),
|
|
113
|
+
...(outcome.evaluation ? { evaluation_hash: proofEvaluationHash(outcome.evaluation) } : {}),
|
|
114
|
+
...(outcome.error_code ? { error_code: outcome.error_code } : {}),
|
|
115
|
+
};
|
|
116
|
+
return ReplayReceiptSchema.parse({ ...body, deterministic_hash: canonicalHash(body) });
|
|
117
|
+
}
|
|
118
|
+
function acceptedHistory(root, plan, env, hooks, timeout) {
|
|
119
|
+
const selector = plan.corpus.accepted_history;
|
|
120
|
+
if (selector.max_commits === 0)
|
|
121
|
+
return { commits: [] };
|
|
122
|
+
if (![selector.from, selector.to].every((ref) => /^[a-f0-9]{40}$/.test(ref) && revExists(ref, root))) {
|
|
123
|
+
return { commits: [], error: "history-ref-unresolved" };
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const raw = execFileSync("git", replayGitArgs(root, hooks, [
|
|
127
|
+
"rev-list",
|
|
128
|
+
...(selector.first_parent ? ["--first-parent"] : []),
|
|
129
|
+
"--reverse",
|
|
130
|
+
`${selector.from}..${selector.to}`,
|
|
131
|
+
]), { env, encoding: "utf8", timeout, stdio: ["ignore", "pipe", "ignore"] });
|
|
132
|
+
const excluded = new Set([...selector.exclude, selector.to]);
|
|
133
|
+
const all = raw.trim().split("\n").filter((sha) => /^[a-f0-9]{40}$/.test(sha) && !excluded.has(sha));
|
|
134
|
+
return { commits: all.slice(-selector.max_commits) };
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
return { commits: [], error: errorCode(e, "history-enumeration-failed") };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Execute an immutable ProofPlan with the current in-process static evaluator.
|
|
141
|
+
* Repository hooks, user-global Git configuration, overlay discovery, project
|
|
142
|
+
* scripts, provider calls, and project tests are outside this path. Every
|
|
143
|
+
* disposable worktree and derived graph is removed in a finally block. */
|
|
144
|
+
export function replayProofPlan(root, policy, inputPlan, opts = {}) {
|
|
145
|
+
const plan = ProofPlanSchema.parse(inputPlan);
|
|
146
|
+
const composition = opts.composition ?? [];
|
|
147
|
+
const policyHash = policyProofHash(policy, composition);
|
|
148
|
+
assertCompositionBinding(policy, composition, plan.composition);
|
|
149
|
+
const evaluate = (snapshot) => composition.length
|
|
150
|
+
? evaluateCompositePolicyOnSnapshot(policy, composition, snapshot)
|
|
151
|
+
: evaluatePolicyOnSnapshot(policy, snapshot);
|
|
152
|
+
if (plan.content_hash !== proofPlanContentHash(plan))
|
|
153
|
+
throw new Error(`proof plan ${plan.id} content hash mismatch`);
|
|
154
|
+
if (plan.policy_id !== policy.id || plan.policy_candidate_hash !== policyHash) {
|
|
155
|
+
throw new Error(`proof plan ${plan.id} does not match policy ${policy.id} semantics`);
|
|
156
|
+
}
|
|
157
|
+
if (plan.evaluator.name !== POLICY_EVALUATOR.name || plan.evaluator.version !== POLICY_EVALUATOR.version) {
|
|
158
|
+
throw new Error(`proof plan ${plan.id} requires evaluator ${plan.evaluator.name}@${plan.evaluator.version}`);
|
|
159
|
+
}
|
|
160
|
+
const cacheBase = join(root, ".hunch-cache", "worktrees");
|
|
161
|
+
mkdirSync(cacheBase, { recursive: true });
|
|
162
|
+
const session = mkdtempSync(join(cacheBase, "replay-"));
|
|
163
|
+
const hooks = join(session, "hooks-disabled");
|
|
164
|
+
const gitConfig = join(session, "global.gitconfig");
|
|
165
|
+
mkdirSync(hooks, { recursive: true });
|
|
166
|
+
writeFileSync(gitConfig, "");
|
|
167
|
+
const env = replaySafeEnvironment(session, gitConfig);
|
|
168
|
+
const unsafeFilter = hasUnsafeReplayFilter(root, env);
|
|
169
|
+
const deadline = Date.now() + plan.budgets.max_minutes * 60_000;
|
|
170
|
+
const outcomes = new Map();
|
|
171
|
+
const cacheStats = { hits: 0, misses: 0, rebuilds: 0, memory_hits: 0 };
|
|
172
|
+
const workerStats = { limit: workerLimit(opts.maxWorkers), peak: 0, scheduled: 0 };
|
|
173
|
+
const active = [];
|
|
174
|
+
try {
|
|
175
|
+
const history = acceptedHistory(root, plan, env, hooks, Math.max(1, deadline - Date.now()));
|
|
176
|
+
const requestedRefs = [
|
|
177
|
+
plan.corpus.current_baseline.ref,
|
|
178
|
+
...plan.corpus.known_bad.map((fixture) => fixture.ref),
|
|
179
|
+
...plan.corpus.known_good.map((fixture) => fixture.ref),
|
|
180
|
+
...history.commits,
|
|
181
|
+
];
|
|
182
|
+
const uniqueCommits = [];
|
|
183
|
+
const seen = new Set();
|
|
184
|
+
for (const ref of requestedRefs) {
|
|
185
|
+
const commit = stableCommit(ref);
|
|
186
|
+
if (seen.has(commit))
|
|
187
|
+
cacheStats.memory_hits++;
|
|
188
|
+
else {
|
|
189
|
+
seen.add(commit);
|
|
190
|
+
uniqueCommits.push(commit);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const pending = [];
|
|
194
|
+
for (const commit of uniqueCommits) {
|
|
195
|
+
if (unsafeFilter) {
|
|
196
|
+
outcomes.set(commit, { commit, error_code: "unsafe-local-filter-config" });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (commit === ZERO_SHA || !revExists(commit, root)) {
|
|
200
|
+
outcomes.set(commit, { commit, error_code: "commit-ref-unresolved" });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (Date.now() >= deadline) {
|
|
204
|
+
outcomes.set(commit, { commit, error_code: "timeout" });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const cached = loadReplaySnapshot(root, commit, plan.data_class);
|
|
208
|
+
if (cached.status === "hit" && cached.snapshot) {
|
|
209
|
+
cacheStats.hits++;
|
|
210
|
+
outcomes.set(commit, {
|
|
211
|
+
commit,
|
|
212
|
+
snapshot: cached.snapshot,
|
|
213
|
+
evaluation: evaluate(cached.snapshot),
|
|
214
|
+
});
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (cached.status === "invalid")
|
|
218
|
+
cacheStats.rebuilds++;
|
|
219
|
+
else
|
|
220
|
+
cacheStats.misses++;
|
|
221
|
+
pending.push(commit);
|
|
222
|
+
}
|
|
223
|
+
while (pending.length || active.length) {
|
|
224
|
+
while (pending.length && active.length < workerStats.limit && Date.now() < deadline) {
|
|
225
|
+
const commit = pending.shift();
|
|
226
|
+
const run = mkdtempSync(join(session, "commit-"));
|
|
227
|
+
const checkout = join(run, "checkout");
|
|
228
|
+
const graph = join(run, "graph");
|
|
229
|
+
let added = false;
|
|
230
|
+
try {
|
|
231
|
+
execFileSync("git", replayGitArgs(root, hooks, ["worktree", "add", "--detach", "--force", checkout, commit]), {
|
|
232
|
+
env,
|
|
233
|
+
timeout: Math.max(1, deadline - Date.now()),
|
|
234
|
+
stdio: "ignore",
|
|
235
|
+
});
|
|
236
|
+
added = true;
|
|
237
|
+
const taskFile = join(run, "task.json");
|
|
238
|
+
const resultFile = join(run, "result.json");
|
|
239
|
+
const errorFile = join(run, "worker.stderr");
|
|
240
|
+
writeFileSync(taskFile, JSON.stringify({ checkout, graph, root, commit }));
|
|
241
|
+
const errorLog = openSync(errorFile, "w");
|
|
242
|
+
let child;
|
|
243
|
+
try {
|
|
244
|
+
child = spawn(process.execPath, replayWorkerArgs(taskFile, resultFile), {
|
|
245
|
+
cwd: root,
|
|
246
|
+
env,
|
|
247
|
+
stdio: ["ignore", "ignore", errorLog],
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
closeSync(errorLog);
|
|
252
|
+
}
|
|
253
|
+
child.unref();
|
|
254
|
+
active.push({ commit, run, checkout, resultFile, errorFile, child });
|
|
255
|
+
workerStats.scheduled++;
|
|
256
|
+
workerStats.peak = Math.max(workerStats.peak, active.length);
|
|
257
|
+
}
|
|
258
|
+
catch (e) {
|
|
259
|
+
const cleanupFailed = added ? cleanupReplayWorktree(root, hooks, env, run, checkout) : false;
|
|
260
|
+
if (!added)
|
|
261
|
+
rmSync(run, { recursive: true, force: true });
|
|
262
|
+
outcomes.set(commit, {
|
|
263
|
+
commit,
|
|
264
|
+
error_code: cleanupFailed
|
|
265
|
+
? "worktree-cleanup-failed"
|
|
266
|
+
: errorCode(e, added ? "snapshot-index-failed" : "worktree-create-failed"),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
let progressed = false;
|
|
271
|
+
for (let index = active.length - 1; index >= 0; index--) {
|
|
272
|
+
const task = active[index];
|
|
273
|
+
const hasResult = existsSync(task.resultFile);
|
|
274
|
+
const failedToStart = !hasResult && existsSync(task.errorFile) && statSync(task.errorFile).size > 0;
|
|
275
|
+
if (!hasResult && !failedToStart)
|
|
276
|
+
continue;
|
|
277
|
+
active.splice(index, 1);
|
|
278
|
+
let message;
|
|
279
|
+
try {
|
|
280
|
+
if (failedToStart) {
|
|
281
|
+
task.child.kill("SIGTERM");
|
|
282
|
+
throw new Error("worker process failed before producing a result");
|
|
283
|
+
}
|
|
284
|
+
message = JSON.parse(readFileSync(task.resultFile, "utf8"));
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
message = { commit: task.commit, error_code: "snapshot-index-failed" };
|
|
288
|
+
}
|
|
289
|
+
const cleanupFailed = cleanupReplayWorktree(root, hooks, env, task.run, task.checkout);
|
|
290
|
+
let outcome;
|
|
291
|
+
if (cleanupFailed)
|
|
292
|
+
outcome = { commit: task.commit, error_code: "worktree-cleanup-failed" };
|
|
293
|
+
else if (Date.now() >= deadline)
|
|
294
|
+
outcome = { commit: task.commit, error_code: "timeout" };
|
|
295
|
+
else if (message.commit !== task.commit || !message.snapshot) {
|
|
296
|
+
outcome = { commit: task.commit, error_code: message.error_code ?? "snapshot-index-failed" };
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
try {
|
|
300
|
+
putReplaySnapshot(root, message.snapshot, plan.data_class);
|
|
301
|
+
}
|
|
302
|
+
catch { /* derived cache failure never changes proof semantics */ }
|
|
303
|
+
outcome = {
|
|
304
|
+
commit: task.commit,
|
|
305
|
+
snapshot: message.snapshot,
|
|
306
|
+
evaluation: evaluate(message.snapshot),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
outcomes.set(task.commit, outcome);
|
|
310
|
+
progressed = true;
|
|
311
|
+
}
|
|
312
|
+
if (Date.now() >= deadline) {
|
|
313
|
+
for (const task of active.splice(0)) {
|
|
314
|
+
task.child.kill("SIGTERM");
|
|
315
|
+
const cleanupFailed = cleanupReplayWorktree(root, hooks, env, task.run, task.checkout);
|
|
316
|
+
outcomes.set(task.commit, { commit: task.commit, error_code: cleanupFailed ? "worktree-cleanup-failed" : "timeout" });
|
|
317
|
+
}
|
|
318
|
+
for (const commit of pending.splice(0))
|
|
319
|
+
outcomes.set(commit, { commit, error_code: "timeout" });
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
if (!progressed && active.length)
|
|
323
|
+
Atomics.wait(POLL_STATE, 0, 0, 10);
|
|
324
|
+
}
|
|
325
|
+
const outcomeFor = (ref) => outcomes.get(stableCommit(ref)) ?? { commit: stableCommit(ref), error_code: "snapshot-index-failed" };
|
|
326
|
+
const currentOutcome = outcomeFor(plan.corpus.current_baseline.ref);
|
|
327
|
+
const current = makeReceipt("current_baseline", plan.corpus.current_baseline.ref, policyHash, plan.corpus.current_baseline.expected, currentOutcome);
|
|
328
|
+
const knownBad = plan.corpus.known_bad.map((fixture) => makeReceipt("known_bad", fixture.ref, policyHash, fixture.expected, outcomeFor(fixture.ref)));
|
|
329
|
+
const knownGood = plan.corpus.known_good.map((fixture) => makeReceipt("known_good", fixture.ref, policyHash, fixture.expected, outcomeFor(fixture.ref)));
|
|
330
|
+
const accepted = history.error
|
|
331
|
+
? [makeReceipt("accepted_history", plan.corpus.accepted_history.to, policyHash, undefined, {
|
|
332
|
+
commit: stableCommit(plan.corpus.accepted_history.to),
|
|
333
|
+
error_code: history.error,
|
|
334
|
+
})]
|
|
335
|
+
: history.commits.map((commit) => makeReceipt("accepted_history", commit, policyHash, undefined, outcomeFor(commit)));
|
|
336
|
+
const receipts = [current, ...knownBad, ...knownGood, ...accepted];
|
|
337
|
+
return {
|
|
338
|
+
current,
|
|
339
|
+
known_bad: knownBad,
|
|
340
|
+
known_good: knownGood,
|
|
341
|
+
accepted_history: accepted,
|
|
342
|
+
replay_receipts: receipts,
|
|
343
|
+
selected_history_commits: history.commits,
|
|
344
|
+
history_complete: !history.error && accepted.every((receipt) => receipt.error_code !== "timeout"),
|
|
345
|
+
cache_stats: cacheStats,
|
|
346
|
+
worker_stats: workerStats,
|
|
347
|
+
...(currentOutcome.snapshot ? { current_snapshot: currentOutcome.snapshot } : {}),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
for (const task of active.splice(0)) {
|
|
352
|
+
task.child.kill("SIGTERM");
|
|
353
|
+
try {
|
|
354
|
+
cleanupReplayWorktree(root, hooks, env, task.run, task.checkout);
|
|
355
|
+
}
|
|
356
|
+
catch { /* primary replay error remains visible */ }
|
|
357
|
+
}
|
|
358
|
+
rmSync(session, { recursive: true, force: true });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
//# sourceMappingURL=replay.js.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { ComponentSchema, EdgeSchema, SymbolSchema } from "../core/types.js";
|
|
5
|
+
import { writeFileAtomic } from "../core/io.js";
|
|
6
|
+
import { canonicalHash } from "./canonical.js";
|
|
7
|
+
import { graphSnapshotFromRecords } from "./evaluator.js";
|
|
8
|
+
import { DataClassSchema, POLICY_EVALUATOR } from "./schema.js";
|
|
9
|
+
export const REPLAY_CACHE_ENGINE = { name: "hunch-tsjs-static-index", version: "3" };
|
|
10
|
+
const ReplayGraphCacheSchema = z.object({
|
|
11
|
+
version: z.literal(1),
|
|
12
|
+
engine: z.object({ name: z.string().min(1), version: z.string().min(1) }),
|
|
13
|
+
evaluator: z.object({ name: z.string().min(1), version: z.string().min(1) }),
|
|
14
|
+
repository: z.string().min(1),
|
|
15
|
+
commit: z.string().regex(/^[a-f0-9]{40}$/),
|
|
16
|
+
data_class: DataClassSchema,
|
|
17
|
+
graph_hash: z.string().min(1),
|
|
18
|
+
symbols: z.array(SymbolSchema),
|
|
19
|
+
edges: z.array(EdgeSchema),
|
|
20
|
+
components: z.array(ComponentSchema),
|
|
21
|
+
content_hash: z.string().min(1),
|
|
22
|
+
}).strict();
|
|
23
|
+
function cacheBody(snapshot, dataClass, engineVersion) {
|
|
24
|
+
return {
|
|
25
|
+
version: 1,
|
|
26
|
+
engine: { name: REPLAY_CACHE_ENGINE.name, version: engineVersion },
|
|
27
|
+
evaluator: { ...POLICY_EVALUATOR },
|
|
28
|
+
repository: basename(snapshot.root),
|
|
29
|
+
commit: snapshot.head,
|
|
30
|
+
data_class: dataClass,
|
|
31
|
+
graph_hash: snapshot.graph_hash,
|
|
32
|
+
symbols: snapshot.symbols,
|
|
33
|
+
edges: snapshot.edges,
|
|
34
|
+
components: snapshot.components,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function replayCacheKey(commit, dataClass, engineVersion = REPLAY_CACHE_ENGINE.version) {
|
|
38
|
+
return canonicalHash({
|
|
39
|
+
version: 1,
|
|
40
|
+
engine: { ...REPLAY_CACHE_ENGINE, version: engineVersion },
|
|
41
|
+
evaluator: POLICY_EVALUATOR,
|
|
42
|
+
commit,
|
|
43
|
+
data_class: dataClass,
|
|
44
|
+
}).replace(/^sha1:/, "");
|
|
45
|
+
}
|
|
46
|
+
export function replayCacheFile(root, commit, dataClass, engineVersion = REPLAY_CACHE_ENGINE.version) {
|
|
47
|
+
return join(root, ".hunch-cache", "replay", dataClass, `${replayCacheKey(commit, dataClass, engineVersion)}.json`);
|
|
48
|
+
}
|
|
49
|
+
export function loadReplaySnapshot(root, commit, dataClass, engineVersion = REPLAY_CACHE_ENGINE.version) {
|
|
50
|
+
const file = replayCacheFile(root, commit, dataClass, engineVersion);
|
|
51
|
+
if (!existsSync(file))
|
|
52
|
+
return { status: "miss" };
|
|
53
|
+
try {
|
|
54
|
+
const cached = ReplayGraphCacheSchema.parse(JSON.parse(readFileSync(file, "utf8")));
|
|
55
|
+
const { content_hash: _hash, ...body } = cached;
|
|
56
|
+
if (cached.content_hash !== canonicalHash(body))
|
|
57
|
+
throw new Error("content hash mismatch");
|
|
58
|
+
if (cached.commit !== commit || cached.data_class !== dataClass)
|
|
59
|
+
throw new Error("cache identity mismatch");
|
|
60
|
+
if (cached.repository !== basename(root))
|
|
61
|
+
throw new Error("cache repository mismatch");
|
|
62
|
+
if (cached.engine.name !== REPLAY_CACHE_ENGINE.name || cached.engine.version !== engineVersion)
|
|
63
|
+
throw new Error("cache engine mismatch");
|
|
64
|
+
if (cached.evaluator.name !== POLICY_EVALUATOR.name || cached.evaluator.version !== POLICY_EVALUATOR.version)
|
|
65
|
+
throw new Error("cache evaluator mismatch");
|
|
66
|
+
const snapshot = graphSnapshotFromRecords(root, commit, cached.symbols, cached.edges, cached.components);
|
|
67
|
+
if (snapshot.graph_hash !== cached.graph_hash)
|
|
68
|
+
throw new Error("graph hash mismatch");
|
|
69
|
+
return { status: "hit", snapshot };
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
try {
|
|
73
|
+
rmSync(file, { force: true });
|
|
74
|
+
}
|
|
75
|
+
catch { /* a derived cache may be rebuilt without deletion */ }
|
|
76
|
+
return { status: "invalid" };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function putReplaySnapshot(root, snapshot, dataClass, engineVersion = REPLAY_CACHE_ENGINE.version) {
|
|
80
|
+
if (!/^[a-f0-9]{40}$/.test(snapshot.head))
|
|
81
|
+
throw new Error("replay cache requires an immutable full commit SHA");
|
|
82
|
+
const body = cacheBody(snapshot, dataClass, engineVersion);
|
|
83
|
+
const record = ReplayGraphCacheSchema.parse({ ...body, content_hash: canonicalHash(body) });
|
|
84
|
+
const file = replayCacheFile(root, snapshot.head, dataClass, engineVersion);
|
|
85
|
+
mkdirSync(join(root, ".hunch-cache", "replay", dataClass), { recursive: true });
|
|
86
|
+
writeFileAtomic(file, JSON.stringify(record, null, 2) + "\n");
|
|
87
|
+
return file;
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=replayCache.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { hunchPathsForDir } from "../core/paths.js";
|
|
3
|
+
import { indexRepo } from "../extractors/indexer.js";
|
|
4
|
+
import { HunchStore } from "../store/hunchStore.js";
|
|
5
|
+
import { graphSnapshot } from "./evaluator.js";
|
|
6
|
+
const taskFile = process.argv[2];
|
|
7
|
+
const resultFile = process.argv[3];
|
|
8
|
+
if (!taskFile || !resultFile)
|
|
9
|
+
throw new Error("replay worker requires task and result files");
|
|
10
|
+
const input = JSON.parse(readFileSync(taskFile, "utf8"));
|
|
11
|
+
let store;
|
|
12
|
+
let message;
|
|
13
|
+
try {
|
|
14
|
+
store = new HunchStore(hunchPathsForDir(input.graph));
|
|
15
|
+
store.json.ensureDirs();
|
|
16
|
+
indexRepo(store, input.checkout, { churn: false });
|
|
17
|
+
message = {
|
|
18
|
+
commit: input.commit,
|
|
19
|
+
snapshot: graphSnapshot(store, input.root, { publicOnly: true, head: input.commit }),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
message = { commit: input.commit, error_code: "snapshot-index-failed" };
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
try {
|
|
27
|
+
store?.close();
|
|
28
|
+
}
|
|
29
|
+
catch { /* the parent still removes the derived graph */ }
|
|
30
|
+
}
|
|
31
|
+
const temporary = `${resultFile}.${process.pid}.tmp`;
|
|
32
|
+
writeFileSync(temporary, JSON.stringify(message));
|
|
33
|
+
renameSync(temporary, resultFile);
|
|
34
|
+
//# sourceMappingURL=replayWorker.js.map
|