@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,703 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { shortHash } from "../core/ids.js";
|
|
7
|
+
import { loadNativeTreeSitter } from "../extractors/nativeTreeSitter.js";
|
|
8
|
+
import { commitMeta, revExists } from "../extractors/git.js";
|
|
9
|
+
import { canonicalHash } from "./canonical.js";
|
|
10
|
+
import { buildG2CandidateReview, positiveBound, } from "./g2Candidates.js";
|
|
11
|
+
import { cleanupReplayWorktree, hasUnsafeReplayFilter, replayGitArgs, replaySafeEnvironment, } from "./replay.js";
|
|
12
|
+
import { dependencySnapshotForCommit } from "./g2BehaviorDependencies.js";
|
|
13
|
+
import { NODE_TEST_REPORTER_SOURCE, exactNodeTestPattern, nodeTestIsolationFlag, nodeTestReporterEvents, } from "./nodeTestEvidence.js";
|
|
14
|
+
const TEST_FILE = /\.(?:test|spec)\.(?:[cm]?[jt]sx?)$/;
|
|
15
|
+
const FULL_SHA = /^[a-f0-9]{40}$/;
|
|
16
|
+
const LIMITATIONS = [
|
|
17
|
+
"Candidates cover newly named node:test test()/it() cases in human-grounded fixing commits; renamed or modified existing tests require explicit review.",
|
|
18
|
+
"Replay transplants the exact known-good test file into disposable known-bad and known-good worktrees and executes one named test without a shell.",
|
|
19
|
+
"Replay never installs historical dependencies or runs package lifecycle scripts; missing dependency snapshots remain explicit diagnostic errors.",
|
|
20
|
+
"A confirmed diagnostic receipt is not Policy IR, corpus evidence, a Constitution proof, activation authority, or G2 approval.",
|
|
21
|
+
];
|
|
22
|
+
const DIRECT_TEST_DELTA_LIMITATIONS = [
|
|
23
|
+
"Direct decision candidates cover newly named node:test test()/it() cases and existing literal-named cases whose body contains an added fixing-commit line.",
|
|
24
|
+
...LIMITATIONS.slice(1),
|
|
25
|
+
];
|
|
26
|
+
const { Parser, typescript: tsLanguage, tsx: tsxLanguage } = loadNativeTreeSitter();
|
|
27
|
+
function safeTestFile(file) {
|
|
28
|
+
return !!file
|
|
29
|
+
&& !isAbsolute(file)
|
|
30
|
+
&& !file.includes("\\")
|
|
31
|
+
&& !file.includes("\0")
|
|
32
|
+
&& !file.split("/").some((part) => part === ".." || part === ".")
|
|
33
|
+
&& TEST_FILE.test(file);
|
|
34
|
+
}
|
|
35
|
+
function fileAt(root, commit, file) {
|
|
36
|
+
if (!FULL_SHA.test(commit) || !safeTestFile(file))
|
|
37
|
+
return null;
|
|
38
|
+
try {
|
|
39
|
+
return execFileSync("git", ["-C", root, "show", `${commit}:${file}`], {
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
42
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function decodeEscaped(raw) {
|
|
50
|
+
let out = "";
|
|
51
|
+
for (let index = 0; index < raw.length; index++) {
|
|
52
|
+
const char = raw[index];
|
|
53
|
+
if (char !== "\\" || index + 1 >= raw.length) {
|
|
54
|
+
out += char;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const next = raw[++index];
|
|
58
|
+
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** Extract literal node:test names without interpreting source or template expressions. */
|
|
63
|
+
export function addedNodeTestNames(source) {
|
|
64
|
+
const names = new Set();
|
|
65
|
+
const start = /\b(?:test|it)\s*\(\s*/g;
|
|
66
|
+
let match;
|
|
67
|
+
while ((match = start.exec(source))) {
|
|
68
|
+
const quote = source[match.index + match[0].length];
|
|
69
|
+
if (quote !== '"' && quote !== "'" && quote !== "`")
|
|
70
|
+
continue;
|
|
71
|
+
let raw = "";
|
|
72
|
+
let escaped = false;
|
|
73
|
+
let closed = false;
|
|
74
|
+
let cursor = match.index + match[0].length + 1;
|
|
75
|
+
for (; cursor < source.length; cursor++) {
|
|
76
|
+
const char = source[cursor];
|
|
77
|
+
if (escaped) {
|
|
78
|
+
raw += `\\${char}`;
|
|
79
|
+
escaped = false;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (char === "\\") {
|
|
83
|
+
escaped = true;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (char === quote) {
|
|
87
|
+
closed = true;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
if (quote === "`" && char === "$" && source[cursor + 1] === "{") {
|
|
91
|
+
raw = "";
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
raw += char;
|
|
95
|
+
}
|
|
96
|
+
if (closed && raw.trim())
|
|
97
|
+
names.add(decodeEscaped(raw));
|
|
98
|
+
start.lastIndex = Math.max(start.lastIndex, cursor + 1);
|
|
99
|
+
}
|
|
100
|
+
return [...names].sort();
|
|
101
|
+
}
|
|
102
|
+
function decodeJsStringBody(raw) {
|
|
103
|
+
let out = "";
|
|
104
|
+
for (let index = 0; index < raw.length; index++) {
|
|
105
|
+
const char = raw[index];
|
|
106
|
+
if (char !== "\\") {
|
|
107
|
+
out += char;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const next = raw[++index];
|
|
111
|
+
if (next == null)
|
|
112
|
+
return null;
|
|
113
|
+
const simple = {
|
|
114
|
+
b: "\b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v", "0": "\0",
|
|
115
|
+
};
|
|
116
|
+
if (next in simple) {
|
|
117
|
+
if (next === "0" && /[0-9]/.test(raw[index + 1] ?? ""))
|
|
118
|
+
return null;
|
|
119
|
+
out += simple[next];
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (next === "x") {
|
|
123
|
+
const hex = raw.slice(index + 1, index + 3);
|
|
124
|
+
if (!/^[a-fA-F0-9]{2}$/.test(hex))
|
|
125
|
+
return null;
|
|
126
|
+
out += String.fromCharCode(Number.parseInt(hex, 16));
|
|
127
|
+
index += 2;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (next === "u") {
|
|
131
|
+
if (raw[index + 1] === "{") {
|
|
132
|
+
const end = raw.indexOf("}", index + 2);
|
|
133
|
+
if (end < 0)
|
|
134
|
+
return null;
|
|
135
|
+
const hex = raw.slice(index + 2, end);
|
|
136
|
+
const codePoint = /^[a-fA-F0-9]{1,6}$/.test(hex) ? Number.parseInt(hex, 16) : -1;
|
|
137
|
+
if (codePoint < 0 || codePoint > 0x10ffff)
|
|
138
|
+
return null;
|
|
139
|
+
out += String.fromCodePoint(codePoint);
|
|
140
|
+
index = end;
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
const hex = raw.slice(index + 1, index + 5);
|
|
144
|
+
if (!/^[a-fA-F0-9]{4}$/.test(hex))
|
|
145
|
+
return null;
|
|
146
|
+
out += String.fromCharCode(Number.parseInt(hex, 16));
|
|
147
|
+
index += 4;
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (next === "\n")
|
|
152
|
+
continue;
|
|
153
|
+
if (next === "\r") {
|
|
154
|
+
if (raw[index + 1] === "\n")
|
|
155
|
+
index++;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
out += next;
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
function literalTestName(raw) {
|
|
163
|
+
const quote = raw[0];
|
|
164
|
+
if ((quote !== '"' && quote !== "'" && quote !== "`") || raw.at(-1) !== quote)
|
|
165
|
+
return null;
|
|
166
|
+
const body = raw.slice(1, -1);
|
|
167
|
+
if (quote === "`" && body.includes("${"))
|
|
168
|
+
return null;
|
|
169
|
+
const name = decodeJsStringBody(body);
|
|
170
|
+
if (name == null)
|
|
171
|
+
return null;
|
|
172
|
+
return name.trim() ? name : null;
|
|
173
|
+
}
|
|
174
|
+
function literalNodeTestCases(file, source) {
|
|
175
|
+
const language = /\.[cm]?[jt]sx$/.test(file) && /x$/.test(file) ? tsxLanguage : tsLanguage;
|
|
176
|
+
const parser = new Parser();
|
|
177
|
+
parser.setLanguage(language);
|
|
178
|
+
let tree;
|
|
179
|
+
try {
|
|
180
|
+
tree = parser.parse(source, undefined, { bufferSize: Math.max(32 * 1024, source.length * 2 + 1024) });
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
const cases = [];
|
|
186
|
+
const visit = (node) => {
|
|
187
|
+
if (node.type === "call_expression") {
|
|
188
|
+
const fn = node.childForFieldName("function");
|
|
189
|
+
const args = node.childForFieldName("arguments");
|
|
190
|
+
const first = args?.namedChildren[0];
|
|
191
|
+
if (fn?.type === "identifier" && (fn.text === "test" || fn.text === "it")
|
|
192
|
+
&& first && (first.type === "string" || first.type === "template_string")) {
|
|
193
|
+
const name = literalTestName(first.text);
|
|
194
|
+
if (name)
|
|
195
|
+
cases.push({ name, startLine: node.startPosition.row + 1, endLine: node.endPosition.row + 1 });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
node.namedChildren.forEach(visit);
|
|
199
|
+
};
|
|
200
|
+
visit(tree.rootNode);
|
|
201
|
+
return cases;
|
|
202
|
+
}
|
|
203
|
+
function addedLineNumbers(root, knownBad, knownGood, file) {
|
|
204
|
+
let diff;
|
|
205
|
+
try {
|
|
206
|
+
diff = execFileSync("git", ["-C", root, "diff", "--unified=0", "--no-ext-diff", knownBad, knownGood, "--", file], {
|
|
207
|
+
encoding: "utf8",
|
|
208
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
209
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return new Set();
|
|
214
|
+
}
|
|
215
|
+
const added = new Set();
|
|
216
|
+
let nextLine = null;
|
|
217
|
+
for (const line of diff.split("\n")) {
|
|
218
|
+
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
|
219
|
+
if (hunk) {
|
|
220
|
+
nextLine = Number(hunk[1]);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (nextLine == null || line.startsWith("\\ No newline"))
|
|
224
|
+
continue;
|
|
225
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
226
|
+
added.add(nextLine++);
|
|
227
|
+
}
|
|
228
|
+
else if (!line.startsWith("-") || line.startsWith("---")) {
|
|
229
|
+
nextLine++;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return added;
|
|
233
|
+
}
|
|
234
|
+
function runnerFor(file, name, exact = false) {
|
|
235
|
+
const kind = /\.(?:[cm]?js)$/.test(file) ? "node-test" : "node-test-tsx";
|
|
236
|
+
const pattern = exact ? exactNodeTestPattern(name) : name;
|
|
237
|
+
return {
|
|
238
|
+
kind,
|
|
239
|
+
argv: kind === "node-test"
|
|
240
|
+
? ["node", "--test", `--test-name-pattern=${pattern}`, file]
|
|
241
|
+
: ["node", "tsx", "--test", `--test-name-pattern=${pattern}`, file],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
export function g2BehaviorCandidateHash(candidate) {
|
|
245
|
+
const { human_review: _humanReview, ...body } = candidate;
|
|
246
|
+
return canonicalHash(body);
|
|
247
|
+
}
|
|
248
|
+
export function g2BehaviorReplayContentHash(receipt) {
|
|
249
|
+
const { id: _id, content_hash: _contentHash, ...body } = receipt;
|
|
250
|
+
return canonicalHash(body);
|
|
251
|
+
}
|
|
252
|
+
export function g2BehaviorReviewContentHash(report) {
|
|
253
|
+
const { id: _id, content_hash: _contentHash, ...body } = report;
|
|
254
|
+
return canonicalHash(body);
|
|
255
|
+
}
|
|
256
|
+
function currentHumanDecision(store, decisionId) {
|
|
257
|
+
if (!/^dec_[A-Za-z0-9_-]+$/.test(decisionId))
|
|
258
|
+
throw new Error("G2 behavior decision id is invalid");
|
|
259
|
+
const decision = store.getRec("decisions", decisionId);
|
|
260
|
+
if (!decision)
|
|
261
|
+
throw new Error(`G2 behavior decision ${decisionId} does not exist`);
|
|
262
|
+
if (decision.status !== "accepted" || decision.superseded_by || decision.valid_to) {
|
|
263
|
+
throw new Error(`G2 behavior decision ${decisionId} is not current and accepted`);
|
|
264
|
+
}
|
|
265
|
+
if (!decision.provenance.source.includes("human_confirmed")) {
|
|
266
|
+
throw new Error(`G2 behavior decision ${decisionId} is not human-confirmed`);
|
|
267
|
+
}
|
|
268
|
+
if (!decision.commit)
|
|
269
|
+
throw new Error(`G2 behavior decision ${decisionId} has no exact fixing commit`);
|
|
270
|
+
return decision;
|
|
271
|
+
}
|
|
272
|
+
function directDecisionCommit(store, root, decisionId) {
|
|
273
|
+
const decision = currentHumanDecision(store, decisionId);
|
|
274
|
+
const meta = commitMeta(decision.commit, root);
|
|
275
|
+
if (!meta)
|
|
276
|
+
throw new Error(`G2 behavior decision ${decisionId} fixing commit is unavailable`);
|
|
277
|
+
let knownBad;
|
|
278
|
+
try {
|
|
279
|
+
knownBad = execFileSync("git", ["-C", root, "rev-parse", `${meta.sha}^`], {
|
|
280
|
+
encoding: "utf8",
|
|
281
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
282
|
+
}).trim();
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
throw new Error(`G2 behavior decision ${decisionId} fixing commit has no first parent`);
|
|
286
|
+
}
|
|
287
|
+
if (!FULL_SHA.test(knownBad))
|
|
288
|
+
throw new Error(`G2 behavior decision ${decisionId} first parent is invalid`);
|
|
289
|
+
return {
|
|
290
|
+
commit: meta.sha,
|
|
291
|
+
subject: meta.subject,
|
|
292
|
+
date: meta.date,
|
|
293
|
+
changedFiles: new Set(meta.files),
|
|
294
|
+
decisionId: decision.id,
|
|
295
|
+
knownBad,
|
|
296
|
+
groundingHash: canonicalHash(decision),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function directDecisionReview(store, root, opts, behaviorResolutions, since, maxCommits, limit) {
|
|
300
|
+
const commit = directDecisionCommit(store, root, opts.decisionId);
|
|
301
|
+
const candidates = [];
|
|
302
|
+
const failures = [];
|
|
303
|
+
let hasModifiedTestCandidate = false;
|
|
304
|
+
for (const file of [...commit.changedFiles].filter(safeTestFile).sort()) {
|
|
305
|
+
const after = fileAt(root, commit.commit, file);
|
|
306
|
+
if (after == null) {
|
|
307
|
+
failures.push({ commit: commit.commit, file, error: "known-good test source unavailable" });
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const before = fileAt(root, commit.knownBad, file) ?? "";
|
|
311
|
+
const previous = new Set(literalNodeTestCases(file, before).map((candidate) => candidate.name));
|
|
312
|
+
const addedLines = addedLineNumbers(root, commit.knownBad, commit.commit, file);
|
|
313
|
+
const afterCases = literalNodeTestCases(file, after);
|
|
314
|
+
const modified = new Set(afterCases
|
|
315
|
+
.filter((candidate) => previous.has(candidate.name)
|
|
316
|
+
&& [...addedLines].some((line) => line >= candidate.startLine && line <= candidate.endLine))
|
|
317
|
+
.map((candidate) => candidate.name));
|
|
318
|
+
const names = [...new Set(afterCases.map((candidate) => candidate.name))]
|
|
319
|
+
.filter((candidate) => !previous.has(candidate) || modified.has(candidate))
|
|
320
|
+
.sort();
|
|
321
|
+
for (const name of names) {
|
|
322
|
+
const grounding = previous.has(name) ? "human_decision_plus_modified_test" : "human_decision_plus_added_test";
|
|
323
|
+
if (grounding === "human_decision_plus_modified_test")
|
|
324
|
+
hasModifiedTestCandidate = true;
|
|
325
|
+
const sourceHash = canonicalHash(after);
|
|
326
|
+
const seed = canonicalHash({
|
|
327
|
+
grounding,
|
|
328
|
+
decision_id: commit.decisionId,
|
|
329
|
+
commit: commit.commit,
|
|
330
|
+
file,
|
|
331
|
+
name,
|
|
332
|
+
source_hash: sourceHash,
|
|
333
|
+
});
|
|
334
|
+
candidates.push({
|
|
335
|
+
id: `g2behavior_${shortHash(seed)}`,
|
|
336
|
+
commit: commit.commit,
|
|
337
|
+
commit_subject: commit.subject,
|
|
338
|
+
commit_date: commit.date,
|
|
339
|
+
statement: name,
|
|
340
|
+
test: { file, name, source_hash: sourceHash },
|
|
341
|
+
runner: runnerFor(file, name, true),
|
|
342
|
+
decision_ids: [commit.decisionId],
|
|
343
|
+
source_candidate_ids: [],
|
|
344
|
+
source_attestation_ids: [],
|
|
345
|
+
proposed_corpus: {
|
|
346
|
+
known_bad: { ref: commit.knownBad, expected: "failed" },
|
|
347
|
+
known_good: { ref: commit.commit, expected: "passed" },
|
|
348
|
+
observed: false,
|
|
349
|
+
},
|
|
350
|
+
grounding,
|
|
351
|
+
data_class: "private",
|
|
352
|
+
authority: "none",
|
|
353
|
+
writes: "none",
|
|
354
|
+
proof_status: "not_run",
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
for (const candidate of candidates) {
|
|
359
|
+
const resolution = behaviorResolutions.find((entry) => (entry.candidate_id === candidate.id && entry.candidate_hash === g2BehaviorCandidateHash(candidate)));
|
|
360
|
+
if (resolution)
|
|
361
|
+
candidate.human_review = resolution;
|
|
362
|
+
}
|
|
363
|
+
candidates.sort((left, right) => left.test.file.localeCompare(right.test.file) || left.test.name.localeCompare(right.test.name));
|
|
364
|
+
const items = candidates.slice(0, limit);
|
|
365
|
+
const reviewCounts = candidates.some((candidate) => candidate.human_review !== undefined) ? {
|
|
366
|
+
selected_candidates: candidates.filter((candidate) => candidate.human_review?.disposition === "selected").length,
|
|
367
|
+
rejected_candidates: candidates.filter((candidate) => candidate.human_review?.disposition === "rejected").length,
|
|
368
|
+
unreviewed_candidates: candidates.filter((candidate) => candidate.human_review === undefined).length,
|
|
369
|
+
} : {};
|
|
370
|
+
const body = {
|
|
371
|
+
structural_review_hash: commit.groundingHash,
|
|
372
|
+
since,
|
|
373
|
+
max_commits: maxCommits,
|
|
374
|
+
limit,
|
|
375
|
+
grounded_rejections_scanned: 0,
|
|
376
|
+
candidate_commits: candidates.length ? 1 : 0,
|
|
377
|
+
behavior_candidates: candidates.length,
|
|
378
|
+
...reviewCounts,
|
|
379
|
+
commits_without_added_tests: candidates.length ? [] : [commit.commit],
|
|
380
|
+
extraction_failures: failures,
|
|
381
|
+
items,
|
|
382
|
+
has_more: candidates.length > items.length,
|
|
383
|
+
limitations: hasModifiedTestCandidate ? DIRECT_TEST_DELTA_LIMITATIONS : LIMITATIONS,
|
|
384
|
+
data_class: "private",
|
|
385
|
+
authority: "none",
|
|
386
|
+
writes: "none",
|
|
387
|
+
proof_status: "not_run",
|
|
388
|
+
grounding_mode: "human_decision_plus_added_test",
|
|
389
|
+
source_decision_id: commit.decisionId,
|
|
390
|
+
source_grounding_hash: commit.groundingHash,
|
|
391
|
+
};
|
|
392
|
+
const contentHash = canonicalHash(body);
|
|
393
|
+
return { id: `g2behaviorcandidates_${shortHash(contentHash)}`, content_hash: contentHash, ...body };
|
|
394
|
+
}
|
|
395
|
+
export function buildG2BehaviorCandidateReview(store, root, opts = {}, resolutions = [], behaviorResolutions = []) {
|
|
396
|
+
const since = (opts.since ?? "180d").trim();
|
|
397
|
+
if (!since || since.length > 100)
|
|
398
|
+
throw new Error("G2 behavior candidate since window must be a non-empty bounded string");
|
|
399
|
+
const maxCommits = positiveBound(opts.maxCommits ?? 100, "G2 behavior candidate maxCommits", 200);
|
|
400
|
+
const limit = positiveBound(opts.limit ?? 30, "G2 behavior candidate limit", 100);
|
|
401
|
+
if (opts.decisionId)
|
|
402
|
+
return directDecisionReview(store, root, opts, behaviorResolutions, since, maxCommits, limit);
|
|
403
|
+
const structural = buildG2CandidateReview(store, root, { since, maxCommits, limit: 100 }, resolutions);
|
|
404
|
+
const rejected = structural.items.filter((candidate) => candidate.human_review?.disposition === "rejected"
|
|
405
|
+
&& candidate.attestation.status !== "unattested_structural_coincidence");
|
|
406
|
+
const commits = new Map();
|
|
407
|
+
for (const candidate of rejected) {
|
|
408
|
+
const review = candidate.human_review;
|
|
409
|
+
const current = commits.get(candidate.commit) ?? {
|
|
410
|
+
commit: candidate.commit,
|
|
411
|
+
subject: candidate.commit_subject,
|
|
412
|
+
date: candidate.commit_date,
|
|
413
|
+
changedFiles: new Set(),
|
|
414
|
+
decisionIds: new Set(),
|
|
415
|
+
sourceCandidateIds: new Set(),
|
|
416
|
+
sourceAttestationIds: new Set(),
|
|
417
|
+
knownBad: candidate.proposed_corpus.known_bad.ref,
|
|
418
|
+
};
|
|
419
|
+
candidate.changed_files.forEach((file) => current.changedFiles.add(file));
|
|
420
|
+
candidate.attestation.decision_ids.forEach((id) => current.decisionIds.add(id));
|
|
421
|
+
current.sourceCandidateIds.add(candidate.id);
|
|
422
|
+
current.sourceAttestationIds.add(review.id);
|
|
423
|
+
commits.set(candidate.commit, current);
|
|
424
|
+
}
|
|
425
|
+
const candidates = [];
|
|
426
|
+
const failures = [];
|
|
427
|
+
const withoutTests = [];
|
|
428
|
+
for (const commit of [...commits.values()].sort((left, right) => left.commit.localeCompare(right.commit))) {
|
|
429
|
+
let found = false;
|
|
430
|
+
for (const file of [...commit.changedFiles].filter(safeTestFile).sort()) {
|
|
431
|
+
const after = fileAt(root, commit.commit, file);
|
|
432
|
+
if (after == null) {
|
|
433
|
+
failures.push({ commit: commit.commit, file, error: "known-good test source unavailable" });
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
const before = fileAt(root, commit.knownBad, file) ?? "";
|
|
437
|
+
const previous = new Set(addedNodeTestNames(before));
|
|
438
|
+
for (const name of addedNodeTestNames(after).filter((candidate) => !previous.has(candidate))) {
|
|
439
|
+
found = true;
|
|
440
|
+
const sourceHash = canonicalHash(after);
|
|
441
|
+
const seed = canonicalHash({ commit: commit.commit, file, name, source_hash: sourceHash });
|
|
442
|
+
candidates.push({
|
|
443
|
+
id: `g2behavior_${shortHash(seed)}`,
|
|
444
|
+
commit: commit.commit,
|
|
445
|
+
commit_subject: commit.subject,
|
|
446
|
+
commit_date: commit.date,
|
|
447
|
+
statement: name,
|
|
448
|
+
test: { file, name, source_hash: sourceHash },
|
|
449
|
+
runner: runnerFor(file, name),
|
|
450
|
+
decision_ids: [...commit.decisionIds].sort(),
|
|
451
|
+
source_candidate_ids: [...commit.sourceCandidateIds].sort(),
|
|
452
|
+
source_attestation_ids: [...commit.sourceAttestationIds].sort(),
|
|
453
|
+
proposed_corpus: {
|
|
454
|
+
known_bad: { ref: commit.knownBad, expected: "failed" },
|
|
455
|
+
known_good: { ref: commit.commit, expected: "passed" },
|
|
456
|
+
observed: false,
|
|
457
|
+
},
|
|
458
|
+
grounding: "rejected_proxy_plus_added_test",
|
|
459
|
+
data_class: "private",
|
|
460
|
+
authority: "none",
|
|
461
|
+
writes: "none",
|
|
462
|
+
proof_status: "not_run",
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (!found)
|
|
467
|
+
withoutTests.push(commit.commit);
|
|
468
|
+
}
|
|
469
|
+
for (const candidate of candidates) {
|
|
470
|
+
const resolution = behaviorResolutions.find((entry) => (entry.candidate_id === candidate.id && entry.candidate_hash === g2BehaviorCandidateHash(candidate)));
|
|
471
|
+
if (resolution)
|
|
472
|
+
candidate.human_review = resolution;
|
|
473
|
+
}
|
|
474
|
+
candidates.sort((left, right) => right.commit_date.localeCompare(left.commit_date)
|
|
475
|
+
|| left.commit.localeCompare(right.commit)
|
|
476
|
+
|| left.test.file.localeCompare(right.test.file)
|
|
477
|
+
|| left.test.name.localeCompare(right.test.name));
|
|
478
|
+
const items = candidates.slice(0, limit);
|
|
479
|
+
const reviewCounts = candidates.some((candidate) => candidate.human_review !== undefined) ? {
|
|
480
|
+
selected_candidates: candidates.filter((candidate) => candidate.human_review?.disposition === "selected").length,
|
|
481
|
+
rejected_candidates: candidates.filter((candidate) => candidate.human_review?.disposition === "rejected").length,
|
|
482
|
+
unreviewed_candidates: candidates.filter((candidate) => candidate.human_review === undefined).length,
|
|
483
|
+
} : {};
|
|
484
|
+
const body = {
|
|
485
|
+
structural_review_hash: structural.content_hash,
|
|
486
|
+
since,
|
|
487
|
+
max_commits: maxCommits,
|
|
488
|
+
limit,
|
|
489
|
+
grounded_rejections_scanned: rejected.length,
|
|
490
|
+
candidate_commits: commits.size,
|
|
491
|
+
behavior_candidates: candidates.length,
|
|
492
|
+
...reviewCounts,
|
|
493
|
+
commits_without_added_tests: withoutTests.sort(),
|
|
494
|
+
extraction_failures: failures.sort((left, right) => left.commit.localeCompare(right.commit) || left.file.localeCompare(right.file)),
|
|
495
|
+
items,
|
|
496
|
+
has_more: candidates.length > items.length,
|
|
497
|
+
limitations: LIMITATIONS,
|
|
498
|
+
data_class: "private",
|
|
499
|
+
authority: "none",
|
|
500
|
+
writes: "none",
|
|
501
|
+
proof_status: "not_run",
|
|
502
|
+
};
|
|
503
|
+
const contentHash = canonicalHash(body);
|
|
504
|
+
return { id: `g2behaviorcandidates_${shortHash(contentHash)}`, content_hash: contentHash, ...body };
|
|
505
|
+
}
|
|
506
|
+
function errorLeg(commit, expected, errorCode, dependencySnapshotId) {
|
|
507
|
+
return {
|
|
508
|
+
commit,
|
|
509
|
+
expected,
|
|
510
|
+
result: "error",
|
|
511
|
+
exit_code: null,
|
|
512
|
+
error_code: errorCode,
|
|
513
|
+
...(dependencySnapshotId ? { dependency_snapshot_id: dependencySnapshotId } : {}),
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
export function nodeTestInfrastructureError(output) {
|
|
517
|
+
if (/ERR_MODULE_NOT_FOUND|Cannot find package|Cannot find module/.test(output))
|
|
518
|
+
return "dependency-snapshot-unavailable";
|
|
519
|
+
if (/Could not locate the bindings file|NODE_MODULE_VERSION|Module did not self-register|dlopen\(/.test(output))
|
|
520
|
+
return "dependency-snapshot-unavailable";
|
|
521
|
+
if (/does not provide an export named|Unknown file extension|ERR_UNKNOWN_FILE_EXTENSION/.test(output))
|
|
522
|
+
return "test-load-failed";
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
function runLeg(root, session, hooks, env, candidate, commit, expected, source, budgetMs) {
|
|
526
|
+
if (!FULL_SHA.test(commit) || !revExists(commit, root))
|
|
527
|
+
return errorLeg(commit, expected, "commit-ref-unresolved");
|
|
528
|
+
const run = mkdtempSync(join(session, `${expected}-`));
|
|
529
|
+
const checkout = join(run, "checkout");
|
|
530
|
+
const dependencySnapshot = dependencySnapshotForCommit(root, commit);
|
|
531
|
+
const dependencySnapshotId = dependencySnapshot?.snapshot.id;
|
|
532
|
+
let added = false;
|
|
533
|
+
let leg;
|
|
534
|
+
try {
|
|
535
|
+
execFileSync("git", replayGitArgs(root, hooks, ["worktree", "add", "--detach", "--force", checkout, commit]), {
|
|
536
|
+
env,
|
|
537
|
+
timeout: budgetMs,
|
|
538
|
+
stdio: "ignore",
|
|
539
|
+
});
|
|
540
|
+
added = true;
|
|
541
|
+
if (dependencySnapshot) {
|
|
542
|
+
symlinkSync(dependencySnapshot.nodeModules, join(checkout, "node_modules"), process.platform === "win32" ? "junction" : "dir");
|
|
543
|
+
}
|
|
544
|
+
const testFile = join(checkout, candidate.test.file);
|
|
545
|
+
mkdirSync(dirname(testFile), { recursive: true });
|
|
546
|
+
writeFileSync(testFile, source);
|
|
547
|
+
const exactEvidence = candidate.grounding === "human_decision_plus_added_test";
|
|
548
|
+
const reporter = join(run, "reporter.mjs");
|
|
549
|
+
const patternArg = candidate.runner.argv.find((arg) => arg.startsWith("--test-name-pattern="))
|
|
550
|
+
?? `--test-name-pattern=${candidate.test.name}`;
|
|
551
|
+
if (exactEvidence)
|
|
552
|
+
writeFileSync(reporter, NODE_TEST_REPORTER_SOURCE);
|
|
553
|
+
const testArgs = exactEvidence
|
|
554
|
+
? [
|
|
555
|
+
"--test",
|
|
556
|
+
nodeTestIsolationFlag(),
|
|
557
|
+
patternArg,
|
|
558
|
+
`--test-reporter=${pathToFileURL(reporter).href}`,
|
|
559
|
+
"--test-reporter-destination=stdout",
|
|
560
|
+
candidate.test.file,
|
|
561
|
+
]
|
|
562
|
+
: ["--test", patternArg, candidate.test.file];
|
|
563
|
+
let args;
|
|
564
|
+
if (candidate.runner.kind === "node-test") {
|
|
565
|
+
args = testArgs;
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
const tsx = dependencySnapshot ? join(dependencySnapshot.nodeModules, "tsx", "dist", "cli.mjs") : "";
|
|
569
|
+
args = existsSync(tsx) ? [tsx, ...testArgs] : null;
|
|
570
|
+
}
|
|
571
|
+
if (!args) {
|
|
572
|
+
leg = errorLeg(commit, expected, "dependency-snapshot-unavailable", dependencySnapshotId);
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
const result = spawnSync(process.execPath, args, {
|
|
576
|
+
cwd: checkout,
|
|
577
|
+
env,
|
|
578
|
+
encoding: "utf8",
|
|
579
|
+
timeout: budgetMs,
|
|
580
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
581
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
582
|
+
shell: false,
|
|
583
|
+
});
|
|
584
|
+
if (result.error) {
|
|
585
|
+
const code = result.error.code === "ETIMEDOUT" ? "timeout" : "runner-failed";
|
|
586
|
+
leg = errorLeg(commit, expected, code, dependencySnapshotId);
|
|
587
|
+
}
|
|
588
|
+
else if (result.signal) {
|
|
589
|
+
leg = errorLeg(commit, expected, result.signal === "SIGTERM" ? "timeout" : "runner-signaled", dependencySnapshotId);
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
const exitCode = result.status ?? null;
|
|
593
|
+
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
|
|
594
|
+
if (exactEvidence) {
|
|
595
|
+
const matches = nodeTestReporterEvents(result.stdout ?? "")
|
|
596
|
+
.filter((event) => event.name === candidate.test.name && !event.skip && !event.todo);
|
|
597
|
+
if (matches.length === 0) {
|
|
598
|
+
leg = errorLeg(commit, expected, nodeTestInfrastructureError(output) ?? "selected-test-not-executed", dependencySnapshotId);
|
|
599
|
+
}
|
|
600
|
+
else if (matches.length > 1) {
|
|
601
|
+
leg = errorLeg(commit, expected, "selected-test-ambiguous", dependencySnapshotId);
|
|
602
|
+
}
|
|
603
|
+
else if (matches[0].type === "test:pass" && exitCode === 0) {
|
|
604
|
+
leg = { commit, expected, result: "passed", exit_code: exitCode, ...(dependencySnapshotId ? { dependency_snapshot_id: dependencySnapshotId } : {}) };
|
|
605
|
+
}
|
|
606
|
+
else if (matches[0].type === "test:fail" && exitCode !== 0) {
|
|
607
|
+
leg = { commit, expected, result: "failed", exit_code: exitCode, ...(dependencySnapshotId ? { dependency_snapshot_id: dependencySnapshotId } : {}) };
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
leg = errorLeg(commit, expected, "runner-outcome-inconsistent", dependencySnapshotId);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
const infrastructureError = exitCode === 0 ? null : nodeTestInfrastructureError(output);
|
|
615
|
+
leg = infrastructureError
|
|
616
|
+
? errorLeg(commit, expected, infrastructureError, dependencySnapshotId)
|
|
617
|
+
: {
|
|
618
|
+
commit,
|
|
619
|
+
expected,
|
|
620
|
+
result: exitCode === 0 ? "passed" : "failed",
|
|
621
|
+
exit_code: exitCode,
|
|
622
|
+
...(dependencySnapshotId ? { dependency_snapshot_id: dependencySnapshotId } : {}),
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
catch (error) {
|
|
629
|
+
const code = error.code === "ETIMEDOUT" ? "timeout" : added ? "runner-setup-failed" : "worktree-create-failed";
|
|
630
|
+
leg = errorLeg(commit, expected, code, dependencySnapshotId);
|
|
631
|
+
}
|
|
632
|
+
finally {
|
|
633
|
+
if (added) {
|
|
634
|
+
const cleanupFailed = cleanupReplayWorktree(root, hooks, env, run, checkout);
|
|
635
|
+
if (cleanupFailed)
|
|
636
|
+
leg = errorLeg(commit, expected, "worktree-cleanup-failed", dependencySnapshotId);
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
rmSync(run, { recursive: true, force: true });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return leg;
|
|
643
|
+
}
|
|
644
|
+
export function replayG2BehaviorCandidate(root, report, candidateId, reviewHash, opts = {}) {
|
|
645
|
+
if (report.content_hash !== g2BehaviorReviewContentHash(report)
|
|
646
|
+
|| report.id !== `g2behaviorcandidates_${shortHash(report.content_hash)}`) {
|
|
647
|
+
throw new Error(`G2 behavior candidate review ${report.id} content hash mismatch`);
|
|
648
|
+
}
|
|
649
|
+
if (reviewHash !== report.content_hash)
|
|
650
|
+
throw new Error("behavior candidate review hash does not match the exact current review packet");
|
|
651
|
+
const candidate = report.items.find((item) => item.id === candidateId);
|
|
652
|
+
if (!candidate)
|
|
653
|
+
throw new Error(`behavior candidate ${candidateId} is not present in review ${report.id}`);
|
|
654
|
+
const budgetMs = positiveBound(opts.timeoutMs ?? 30_000, "G2 behavior replay timeoutMs", 120_000);
|
|
655
|
+
const source = fileAt(root, candidate.proposed_corpus.known_good.ref, candidate.test.file);
|
|
656
|
+
if (source == null || canonicalHash(source) !== candidate.test.source_hash) {
|
|
657
|
+
throw new Error(`behavior candidate ${candidate.id} known-good test source hash mismatch`);
|
|
658
|
+
}
|
|
659
|
+
mkdirSync(join(root, ".hunch-cache", "worktrees"), { recursive: true });
|
|
660
|
+
const session = mkdtempSync(join(tmpdir(), "hunch-behavior-"));
|
|
661
|
+
const hooks = join(session, "hooks-disabled");
|
|
662
|
+
const gitConfig = join(session, "global.gitconfig");
|
|
663
|
+
mkdirSync(hooks, { recursive: true });
|
|
664
|
+
writeFileSync(gitConfig, "");
|
|
665
|
+
const env = replaySafeEnvironment(session, gitConfig);
|
|
666
|
+
let knownBad;
|
|
667
|
+
let knownGood;
|
|
668
|
+
try {
|
|
669
|
+
if (hasUnsafeReplayFilter(root, env)) {
|
|
670
|
+
knownBad = errorLeg(candidate.proposed_corpus.known_bad.ref, "failed", "unsafe-local-filter-config");
|
|
671
|
+
knownGood = errorLeg(candidate.proposed_corpus.known_good.ref, "passed", "unsafe-local-filter-config");
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
knownBad = runLeg(root, session, hooks, env, candidate, candidate.proposed_corpus.known_bad.ref, "failed", source, budgetMs);
|
|
675
|
+
knownGood = runLeg(root, session, hooks, env, candidate, candidate.proposed_corpus.known_good.ref, "passed", source, budgetMs);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
finally {
|
|
679
|
+
rmSync(session, { recursive: true, force: true });
|
|
680
|
+
}
|
|
681
|
+
const verdict = knownBad.result === "failed" && knownGood.result === "passed"
|
|
682
|
+
? "behavior_confirmed"
|
|
683
|
+
: "inconclusive";
|
|
684
|
+
const body = {
|
|
685
|
+
candidate_id: candidate.id,
|
|
686
|
+
candidate_hash: g2BehaviorCandidateHash(candidate),
|
|
687
|
+
review_hash: report.content_hash,
|
|
688
|
+
test: candidate.test,
|
|
689
|
+
runner: candidate.runner,
|
|
690
|
+
known_bad: knownBad,
|
|
691
|
+
known_good: knownGood,
|
|
692
|
+
verdict,
|
|
693
|
+
budget_ms: budgetMs,
|
|
694
|
+
limitations: LIMITATIONS,
|
|
695
|
+
data_class: "private",
|
|
696
|
+
authority: "none",
|
|
697
|
+
effects: "diagnostic_only",
|
|
698
|
+
writes: "disposable_only",
|
|
699
|
+
};
|
|
700
|
+
const contentHash = canonicalHash(body);
|
|
701
|
+
return { id: `g2behaviorreplay_${shortHash(contentHash)}`, content_hash: contentHash, ...body };
|
|
702
|
+
}
|
|
703
|
+
//# sourceMappingURL=g2BehaviorCandidates.js.map
|