@davesheffer/hunch 1.8.2 → 1.9.2
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 +96 -1
- package/dist/cli/index.js +1238 -396
- package/dist/constitution/adapters.js +31 -14
- package/dist/constitution/behaviorEvaluator.js +20 -7
- package/dist/constitution/behaviorProof.js +3 -2
- package/dist/constitution/canonical.js +7 -1
- package/dist/constitution/card.js +7 -2
- package/dist/constitution/compiler.js +71 -1
- package/dist/constitution/correctionPolicyMaterializer.js +496 -0
- package/dist/constitution/delta.js +3 -2
- package/dist/constitution/evaluator.js +29 -3
- package/dist/constitution/experiment.js +96 -5
- package/dist/constitution/experimentRunner.js +43 -14
- package/dist/constitution/g2BehaviorCandidates.js +49 -26
- package/dist/constitution/g2BehaviorDependencies.js +203 -14
- package/dist/constitution/g2Candidates.js +1 -1
- package/dist/constitution/lifecycle.js +17 -0
- package/dist/constitution/plan.js +26 -9
- package/dist/constitution/replacementFreeGit.js +67 -0
- package/dist/constitution/replay.js +6 -0
- package/dist/constitution/replayCache.js +1 -1
- package/dist/constitution/replayWorker.js +1 -1
- package/dist/constitution/repository.js +141 -5
- package/dist/constitution/safeCheckout.js +75 -0
- package/dist/constitution/schema.js +30 -5
- package/dist/constitution/service.js +74 -14
- package/dist/constitution/sourceMutation.js +65 -12
- package/dist/constitution/staticGraphBaseline.js +44 -0
- package/dist/constitution/structural.js +60 -4
- package/dist/core/autoreview.js +1 -1
- package/dist/core/canonicalOrder.js +6 -0
- package/dist/core/conformance.js +68 -27
- package/dist/core/docscan.js +2 -1
- package/dist/core/escalations.js +11 -0
- package/dist/core/io.js +44 -9
- package/dist/core/overlaySafety.js +178 -0
- package/dist/core/paths.js +13 -2
- package/dist/core/safeRepoFile.js +74 -0
- package/dist/extractors/comments.js +6 -8
- package/dist/extractors/git.js +1631 -82
- package/dist/extractors/indexer.js +86 -47
- package/dist/extractors/repoSource.js +390 -0
- package/dist/integrations/ciAction.js +10 -2
- package/dist/integrations/gitignore.js +44 -5
- package/dist/integrations/mergeDriver.js +23 -5
- package/dist/integrations/sync.js +61 -5
- package/dist/integrations/team.js +666 -23
- package/dist/mcp/server.js +261 -34
- package/dist/store/db.js +57 -7
- package/dist/store/hunchStore.js +92 -11
- package/dist/store/jsonStore.js +350 -63
- package/dist/store/schema.js +27 -11
- package/dist/synthesis/provider.js +13 -4
- package/dist/synthesis/synthesize.js +56 -19
- package/dist/wiki/graph.js +5 -4
- package/dist/wiki/wiki.js +16 -10
- package/package.json +15 -3
- package/tooling/competitive-watch.mjs +108 -0
- package/tooling/md1-benchmark.mjs +628 -0
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { writeFileAtomic } from "../core/io.js";
|
|
5
5
|
import { shortHash } from "../core/ids.js";
|
|
6
|
+
import { compareCodeUnits } from "../core/canonicalOrder.js";
|
|
6
7
|
import { canonicalHash } from "./canonical.js";
|
|
7
8
|
import { currentAppendOnly, loadPrivateRecords } from "./g2.js";
|
|
8
9
|
const HASH = /^sha1:[a-f0-9]{40}$/;
|
|
@@ -245,6 +246,46 @@ export function assignmentTreatment(bank, run, assignment) {
|
|
|
245
246
|
throw new Error(`assignment ${assignment.id} treatment hash mismatch`);
|
|
246
247
|
return treatment;
|
|
247
248
|
}
|
|
249
|
+
/** Human-facing help is deliberately outside the hash-bound treatment. It may
|
|
250
|
+
* explain the review task, but must never interpret the assigned evidence. */
|
|
251
|
+
export function experimentReviewGuide(arm) {
|
|
252
|
+
const notEnough = {
|
|
253
|
+
value: "uncompilable",
|
|
254
|
+
label: "Not enough information",
|
|
255
|
+
use_when: "The requirement does not clearly say which code relationship must always hold.",
|
|
256
|
+
};
|
|
257
|
+
if (arm === "A") {
|
|
258
|
+
return {
|
|
259
|
+
title: "Write one code rule from the requirement",
|
|
260
|
+
question: "Can one exact code rule be written from the requirement without guessing?",
|
|
261
|
+
action: "If yes, write one sentence naming the code elements, required or forbidden relationship, direct or transitive meaning, and file scope. If no, choose Not enough information.",
|
|
262
|
+
answer_template: "<subject> must <directly or transitively> <required or forbidden relationship> <target>; scope: <file or component>",
|
|
263
|
+
warning: "Use only the stated requirement. Current code may confirm names, but cannot add intent.",
|
|
264
|
+
choices: [
|
|
265
|
+
{ value: "accepted_precise", label: "Rule written", use_when: "Your sentence expresses one exact rule fully supported by the requirement." },
|
|
266
|
+
notEnough,
|
|
267
|
+
],
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
const choices = [
|
|
271
|
+
{ value: "accepted_precise", label: "Yes — exact match", use_when: "The proposed rule says exactly what the requirement says." },
|
|
272
|
+
{ value: "accepted_edited", label: "Needs editing", use_when: "The requirement supports one rule, but the proposal needs a specific correction." },
|
|
273
|
+
{ value: "rejected", label: "Unsupported", use_when: "The proposal adds or changes meaning that the requirement does not support." },
|
|
274
|
+
notEnough,
|
|
275
|
+
];
|
|
276
|
+
return {
|
|
277
|
+
title: arm === "C" ? "Check the proposed rule and its proof card" : "Check the proposed rule",
|
|
278
|
+
question: "Does the proposed rule say exactly what the requirement says?",
|
|
279
|
+
action: arm === "C"
|
|
280
|
+
? "Compare the requirement with the proposed rule, then use the proof card only to check that the named code targets are bound correctly."
|
|
281
|
+
: "Compare the requirement with the proposed rule. Check the relationship, direction, direct or transitive meaning, and scope.",
|
|
282
|
+
answer_template: "Choose one plain-language option; include corrected rule text only for Needs editing.",
|
|
283
|
+
warning: arm === "C"
|
|
284
|
+
? "The proof card can verify code bindings, but it cannot add intent missing from the requirement."
|
|
285
|
+
: "Do not infer intent from the current implementation or from nearby code.",
|
|
286
|
+
choices,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
248
289
|
export function experimentRunContentHash(run) {
|
|
249
290
|
const { id: _id, content_hash: _hash, ...body } = run;
|
|
250
291
|
return canonicalHash(body);
|
|
@@ -272,7 +313,7 @@ export function compileExperimentRun(input, preregistration, bank, opts = {}) {
|
|
|
272
313
|
if (bank.cases.length !== input.sample_per_arm)
|
|
273
314
|
throw new Error("EXP-01 requires exactly sample_per_arm fresh case templates; every case is run once in every arm");
|
|
274
315
|
for (const item of bank.cases) {
|
|
275
|
-
const orderedArms = [...arms].sort((a, b) => rank(preregistration.assignment.seed, item.block, item.id, a)
|
|
316
|
+
const orderedArms = [...arms].sort((a, b) => compareCodeUnits(rank(preregistration.assignment.seed, item.block, item.id, a), rank(preregistration.assignment.seed, item.block, item.id, b)));
|
|
276
317
|
for (const arm of orderedArms) {
|
|
277
318
|
const treatmentHash = canonicalHash(treatmentFor(preregistration.experiment, arm, item));
|
|
278
319
|
const id = `expassign_${shortHash(canonicalHash({ preregistration: preregistration.content_hash, bank: bank.content_hash, case_id: item.id, arm, treatment_hash: treatmentHash }))}`;
|
|
@@ -287,9 +328,9 @@ export function compileExperimentRun(input, preregistration, bank, opts = {}) {
|
|
|
287
328
|
for (const item of bank.cases)
|
|
288
329
|
byBlock.set(item.block, [...(byBlock.get(item.block) ?? []), item]);
|
|
289
330
|
let offset = 0;
|
|
290
|
-
for (const [block, blockCases] of [...byBlock.entries()].sort(([a], [b]) => a
|
|
291
|
-
const ordered = [...blockCases].sort((a, b) => rank(preregistration.assignment.seed, block, a.id)
|
|
292
|
-
const armOrder = [...arms].sort((a, b) => rank(preregistration.assignment.seed, block, "arm", a)
|
|
331
|
+
for (const [block, blockCases] of [...byBlock.entries()].sort(([a], [b]) => compareCodeUnits(a, b))) {
|
|
332
|
+
const ordered = [...blockCases].sort((a, b) => compareCodeUnits(rank(preregistration.assignment.seed, block, a.id), rank(preregistration.assignment.seed, block, b.id)));
|
|
333
|
+
const armOrder = [...arms].sort((a, b) => compareCodeUnits(rank(preregistration.assignment.seed, block, "arm", a), rank(preregistration.assignment.seed, block, "arm", b)));
|
|
293
334
|
for (const [index, item] of ordered.entries()) {
|
|
294
335
|
const arm = armOrder[(offset + index) % armOrder.length];
|
|
295
336
|
const treatmentHash = canonicalHash(treatmentFor(preregistration.experiment, arm, item));
|
|
@@ -302,7 +343,7 @@ export function compileExperimentRun(input, preregistration, bank, opts = {}) {
|
|
|
302
343
|
if ([...counts.values()].some((count) => count !== input.sample_per_arm))
|
|
303
344
|
throw new Error("EXP-03 blocks cannot be assigned to an exactly balanced arm allocation; adjust block sizes");
|
|
304
345
|
}
|
|
305
|
-
assignments.sort((a, b) => rank(preregistration.assignment.seed, "execution", a.id)
|
|
346
|
+
assignments.sort((a, b) => compareCodeUnits(rank(preregistration.assignment.seed, "execution", a.id), rank(preregistration.assignment.seed, "execution", b.id)));
|
|
306
347
|
assignments.forEach((assignment, order) => { assignment.order = order; });
|
|
307
348
|
if (assignments.length > preregistration.sample_plan.maximum_total)
|
|
308
349
|
throw new Error("assignment count exceeds preregistered maximum_total");
|
|
@@ -537,6 +578,38 @@ export function compileExperimentReviewStart(run, assignment, reviewer, opts = {
|
|
|
537
578
|
const contentHash = canonicalHash(body);
|
|
538
579
|
return ExperimentReviewStartSchema.parse({ id: `expreview_${shortHash(contentHash)}`, content_hash: contentHash, ...body });
|
|
539
580
|
}
|
|
581
|
+
export const ExperimentReviewerQualificationSchema = z.object({
|
|
582
|
+
id: z.string().regex(/^expreviewqual_[a-f0-9]{10}$/),
|
|
583
|
+
content_hash: z.string().regex(HASH),
|
|
584
|
+
preregistration_id: z.string().regex(/^expreg_[a-f0-9]{10}$/),
|
|
585
|
+
preregistration_hash: z.string().regex(HASH),
|
|
586
|
+
reviewer: z.string().regex(/^human:[^\s]+$/i),
|
|
587
|
+
protocol: z.literal("exp03-plain-language-comprehension-v2"),
|
|
588
|
+
cases_hash: z.string().regex(HASH),
|
|
589
|
+
passed: z.literal(true),
|
|
590
|
+
reason: z.string().trim().min(1).max(4000),
|
|
591
|
+
data_class: z.literal("private"),
|
|
592
|
+
authority: z.literal("none"),
|
|
593
|
+
recorded_at: z.string().datetime({ offset: true }),
|
|
594
|
+
}).strict();
|
|
595
|
+
export function experimentReviewerQualificationContentHash(record) {
|
|
596
|
+
const { id: _id, content_hash: _hash, ...body } = record;
|
|
597
|
+
return canonicalHash(body);
|
|
598
|
+
}
|
|
599
|
+
export function compileExperimentReviewerQualification(input, preregistration, opts = {}) {
|
|
600
|
+
if (preregistration.experiment !== "EXP-03" || preregistration.revision < 2)
|
|
601
|
+
throw new Error("plain-language reviewer qualification requires EXP-03 revision 2 or later");
|
|
602
|
+
if (input.preregistration_id !== preregistration.id || input.preregistration_hash !== preregistration.content_hash)
|
|
603
|
+
throw new Error("reviewer qualification must bind the exact current preregistration");
|
|
604
|
+
const body = {
|
|
605
|
+
...input,
|
|
606
|
+
data_class: "private",
|
|
607
|
+
authority: "none",
|
|
608
|
+
recorded_at: opts.now ?? new Date().toISOString(),
|
|
609
|
+
};
|
|
610
|
+
const contentHash = canonicalHash(body);
|
|
611
|
+
return ExperimentReviewerQualificationSchema.parse({ id: `expreviewqual_${shortHash(contentHash)}`, content_hash: contentHash, ...body });
|
|
612
|
+
}
|
|
540
613
|
export const ExperimentFollowupSchema = z.object({
|
|
541
614
|
id: z.string().regex(/^expfollow_[a-f0-9]{10}$/),
|
|
542
615
|
content_hash: z.string().regex(HASH),
|
|
@@ -943,6 +1016,24 @@ export class ExperimentRepository {
|
|
|
943
1016
|
this.put("experiment-review-starts", parsed.id, parsed);
|
|
944
1017
|
return parsed;
|
|
945
1018
|
}
|
|
1019
|
+
listReviewerQualifications() {
|
|
1020
|
+
return this.load("experiment-review-qualifications", "expreviewqual_", (raw) => {
|
|
1021
|
+
const parsed = ExperimentReviewerQualificationSchema.parse(raw);
|
|
1022
|
+
if (experimentReviewerQualificationContentHash(parsed) !== parsed.content_hash || parsed.id !== `expreviewqual_${shortHash(parsed.content_hash)}`)
|
|
1023
|
+
throw new Error(`experiment reviewer qualification ${parsed.id} content hash mismatch`);
|
|
1024
|
+
return parsed;
|
|
1025
|
+
}).sort((a, b) => a.id.localeCompare(b.id));
|
|
1026
|
+
}
|
|
1027
|
+
putReviewerQualification(record) {
|
|
1028
|
+
const parsed = ExperimentReviewerQualificationSchema.parse(record);
|
|
1029
|
+
if (experimentReviewerQualificationContentHash(parsed) !== parsed.content_hash || parsed.id !== `expreviewqual_${shortHash(parsed.content_hash)}`)
|
|
1030
|
+
throw new Error(`experiment reviewer qualification ${parsed.id} content hash mismatch`);
|
|
1031
|
+
const incumbent = this.listReviewerQualifications().find((item) => item.preregistration_id === parsed.preregistration_id && item.reviewer === parsed.reviewer);
|
|
1032
|
+
if (incumbent)
|
|
1033
|
+
return incumbent;
|
|
1034
|
+
this.put("experiment-review-qualifications", parsed.id, parsed);
|
|
1035
|
+
return parsed;
|
|
1036
|
+
}
|
|
946
1037
|
listFollowups() {
|
|
947
1038
|
const records = this.load("experiment-followups", "expfollow_", (raw) => {
|
|
948
1039
|
const parsed = ExperimentFollowupSchema.parse(raw);
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync,
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { canonicalHash } from "./canonical.js";
|
|
8
|
+
import { hasUnsafeCheckoutAttributes } from "./safeCheckout.js";
|
|
9
|
+
import { hasUnsafeReplayFilter, replayGitArgs, replaySafeEnvironment } from "./replay.js";
|
|
10
|
+
import { replacementFreeExactCommit, replacementFreeGitEnvironment } from "./replacementFreeGit.js";
|
|
11
|
+
import { materializeDependencyTree } from "./g2BehaviorDependencies.js";
|
|
8
12
|
import { Exp01CaseSchema, assignmentTreatment, compileExperimentOutcome, } from "./experiment.js";
|
|
9
13
|
const EvaluatorOutputSchema = z.object({
|
|
10
14
|
valid_completion: z.boolean(),
|
|
@@ -167,7 +171,13 @@ function runCommand(spec, cwd) {
|
|
|
167
171
|
return { stdout: result.stdout ?? "", stderr: result.stderr ?? "", status: result.status, errorCode };
|
|
168
172
|
}
|
|
169
173
|
function git(root, args) {
|
|
170
|
-
return execFileSync("git", args, {
|
|
174
|
+
return execFileSync("git", args, {
|
|
175
|
+
cwd: root,
|
|
176
|
+
encoding: "utf8",
|
|
177
|
+
env: replacementFreeGitEnvironment(),
|
|
178
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
179
|
+
timeout: 120_000,
|
|
180
|
+
}).trim();
|
|
171
181
|
}
|
|
172
182
|
function removeAmbientInstructions(cwd) {
|
|
173
183
|
for (const relative of ["AGENTS.md", "CLAUDE.md", ".mcp.json", ".claude", ".codex", ".agents"]) {
|
|
@@ -177,15 +187,15 @@ function removeAmbientInstructions(cwd) {
|
|
|
177
187
|
// file replaces any tracked AGENTS.md after the ambient copy is removed.
|
|
178
188
|
writeFileSync(join(cwd, "AGENTS.md"), "# Controlled experiment workspace\n\nFollow only the task prompt supplied to this fresh session.\n");
|
|
179
189
|
}
|
|
180
|
-
function
|
|
190
|
+
function materializeDependencies(source, cwd) {
|
|
181
191
|
const target = join(source, "node_modules");
|
|
182
|
-
const
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
192
|
+
const destination = join(cwd, "node_modules");
|
|
193
|
+
if (existsSync(target))
|
|
194
|
+
materializeDependencyTree(target, destination);
|
|
195
|
+
return destination;
|
|
186
196
|
}
|
|
187
197
|
function countEdits(cwd) {
|
|
188
|
-
const text = git(cwd, ["diff", "--numstat", "--", "."]);
|
|
198
|
+
const text = git(cwd, ["diff", "--no-ext-diff", "--no-textconv", "--numstat", "--", "."]);
|
|
189
199
|
if (!text)
|
|
190
200
|
return 0;
|
|
191
201
|
return text.split(/\r?\n/).reduce((sum, line) => {
|
|
@@ -237,11 +247,16 @@ export function executeExp01Assignment(repository, run, bank, assignment, opts =
|
|
|
237
247
|
if (existing)
|
|
238
248
|
return existing;
|
|
239
249
|
const item = Exp01CaseSchema.parse(bank.cases.find((candidate) => candidate.id === assignment.case_id));
|
|
240
|
-
const sourceHead =
|
|
250
|
+
const sourceHead = replacementFreeExactCommit(bank.repository_root, bank.base_commit);
|
|
241
251
|
if (sourceHead !== bank.base_commit)
|
|
242
252
|
throw new Error("case bank base_commit is not an exact commit in repository_root");
|
|
243
253
|
const session = mkdtempSync(join(tmpdir(), "hunch-exp01-"));
|
|
244
254
|
const cwd = join(session, "worktree");
|
|
255
|
+
const hooks = join(session, "hooks-disabled");
|
|
256
|
+
const gitConfig = join(session, "global.gitconfig");
|
|
257
|
+
mkdirSync(hooks, { recursive: true });
|
|
258
|
+
writeFileSync(gitConfig, "");
|
|
259
|
+
const gitEnv = replaySafeEnvironment(session, gitConfig);
|
|
245
260
|
let added = false;
|
|
246
261
|
let invocationStarted = false;
|
|
247
262
|
try {
|
|
@@ -249,9 +264,19 @@ export function executeExp01Assignment(repository, run, bank, assignment, opts =
|
|
|
249
264
|
if (currentProviderVersion !== run.runner.provider_version) {
|
|
250
265
|
return failureOutcome(repository, run, assignment, "infrastructure_failure", false, "Selected subscription CLI version drifted after assignment; assignment was excluded before model invocation.", "provider-version-drift", { evaluator: canonicalHash({ expected: run.runner.provider_version, actual: currentProviderVersion }) }, opts.now);
|
|
251
266
|
}
|
|
252
|
-
|
|
267
|
+
if (hasUnsafeReplayFilter(bank.repository_root, gitEnv)) {
|
|
268
|
+
return failureOutcome(repository, run, assignment, "infrastructure_failure", false, "Repository-local Git content filters make the controlled checkout unsafe.", "unsafe-local-filter-config", {}, opts.now);
|
|
269
|
+
}
|
|
270
|
+
if (hasUnsafeCheckoutAttributes(bank.repository_root, bank.base_commit, gitEnv, { allowDisabledLfs: true })) {
|
|
271
|
+
return failureOutcome(repository, run, assignment, "infrastructure_failure", false, "Git checkout attributes could transform or execute content outside the locked case bytes.", "unsafe-checkout-attributes", {}, opts.now);
|
|
272
|
+
}
|
|
273
|
+
execFileSync("git", replayGitArgs(bank.repository_root, hooks, ["worktree", "add", "--detach", "--force", cwd, bank.base_commit]), {
|
|
274
|
+
env: gitEnv,
|
|
275
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
276
|
+
timeout: 120_000,
|
|
277
|
+
});
|
|
253
278
|
added = true;
|
|
254
|
-
|
|
279
|
+
const dependencyRoot = materializeDependencies(bank.repository_root, cwd);
|
|
255
280
|
removeAmbientInstructions(cwd);
|
|
256
281
|
if (item.setup) {
|
|
257
282
|
if (!isAbsolute(item.setup.artifact) || !existsSync(item.setup.artifact) || externalArtifactHash(item.setup.artifact) !== item.setup.artifact_hash) {
|
|
@@ -271,9 +296,9 @@ export function executeExp01Assignment(repository, run, bank, assignment, opts =
|
|
|
271
296
|
}
|
|
272
297
|
const treatment = assignmentTreatment(bank, run, assignment);
|
|
273
298
|
invocationStarted = true;
|
|
274
|
-
const agent = invokeAgent(run, cwd, controlledPrompt(treatment), opts.timeoutMs ?? 30 * 60 * 1000,
|
|
299
|
+
const agent = invokeAgent(run, cwd, controlledPrompt(treatment), opts.timeoutMs ?? 30 * 60 * 1000, dependencyRoot);
|
|
275
300
|
const outputHash = canonicalHash(agent.stdout);
|
|
276
|
-
const diff = git(cwd, ["diff", "--binary", "--", "."]);
|
|
301
|
+
const diff = git(cwd, ["diff", "--no-ext-diff", "--no-textconv", "--binary", "--", "."]);
|
|
277
302
|
const diffHash = canonicalHash(diff);
|
|
278
303
|
if (agent.errorCode) {
|
|
279
304
|
return failureOutcome(repository, run, assignment, "invalid_completion", true, "Subscription CLI did not produce a successful terminal run; outcome remains visible and unscored.", agent.errorCode, { output: outputHash, diff: diffHash }, opts.now);
|
|
@@ -333,7 +358,11 @@ export function executeExp01Assignment(repository, run, bank, assignment, opts =
|
|
|
333
358
|
finally {
|
|
334
359
|
try {
|
|
335
360
|
if (added)
|
|
336
|
-
execFileSync("git", ["worktree", "remove", "--force", cwd], {
|
|
361
|
+
execFileSync("git", replayGitArgs(bank.repository_root, hooks, ["worktree", "remove", "--force", cwd]), {
|
|
362
|
+
env: gitEnv,
|
|
363
|
+
stdio: "ignore",
|
|
364
|
+
timeout: 120_000,
|
|
365
|
+
});
|
|
337
366
|
}
|
|
338
367
|
catch {
|
|
339
368
|
// The assignment outcome already records any execution failure; prune is best effort.
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync, mkdtempSync, rmSync,
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { dirname, isAbsolute, join } from "node:path";
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
6
|
import { shortHash } from "../core/ids.js";
|
|
7
|
+
import { compareCodeUnits } from "../core/canonicalOrder.js";
|
|
7
8
|
import { loadNativeTreeSitter } from "../extractors/nativeTreeSitter.js";
|
|
8
|
-
import { commitMeta, revExists } from "../extractors/git.js";
|
|
9
9
|
import { canonicalHash } from "./canonical.js";
|
|
10
10
|
import { buildG2CandidateReview, positiveBound, } from "./g2Candidates.js";
|
|
11
11
|
import { cleanupReplayWorktree, hasUnsafeReplayFilter, replayGitArgs, replaySafeEnvironment, } from "./replay.js";
|
|
12
|
-
import { dependencySnapshotForCommit } from "./g2BehaviorDependencies.js";
|
|
12
|
+
import { dependencySnapshotForCommit, materializeDependencySnapshot } from "./g2BehaviorDependencies.js";
|
|
13
|
+
import { hasUnsafeCheckoutAttributes } from "./safeCheckout.js";
|
|
14
|
+
import { replacementFreeCommitFiles, replacementFreeExactCommit, replacementFreeGitEnvironment, } from "./replacementFreeGit.js";
|
|
13
15
|
import { NODE_TEST_REPORTER_SOURCE, exactNodeTestPattern, nodeTestIsolationFlag, nodeTestReporterEvents, } from "./nodeTestEvidence.js";
|
|
14
16
|
const TEST_FILE = /\.(?:test|spec)\.(?:[cm]?[jt]sx?)$/;
|
|
15
17
|
const FULL_SHA = /^[a-f0-9]{40}$/;
|
|
@@ -38,6 +40,7 @@ function fileAt(root, commit, file) {
|
|
|
38
40
|
try {
|
|
39
41
|
return execFileSync("git", ["-C", root, "show", `${commit}:${file}`], {
|
|
40
42
|
encoding: "utf8",
|
|
43
|
+
env: replacementFreeGitEnvironment(),
|
|
41
44
|
maxBuffer: 10 * 1024 * 1024,
|
|
42
45
|
stdio: ["ignore", "pipe", "ignore"],
|
|
43
46
|
});
|
|
@@ -203,8 +206,9 @@ function literalNodeTestCases(file, source) {
|
|
|
203
206
|
function addedLineNumbers(root, knownBad, knownGood, file) {
|
|
204
207
|
let diff;
|
|
205
208
|
try {
|
|
206
|
-
diff = execFileSync("git", ["-C", root, "diff", "--unified=0", "--no-ext-diff", knownBad, knownGood, "--", file], {
|
|
209
|
+
diff = execFileSync("git", ["-C", root, "diff", "--unified=0", "--no-ext-diff", "--no-textconv", knownBad, knownGood, "--", file], {
|
|
207
210
|
encoding: "utf8",
|
|
211
|
+
env: replacementFreeGitEnvironment(),
|
|
208
212
|
maxBuffer: 10 * 1024 * 1024,
|
|
209
213
|
stdio: ["ignore", "pipe", "ignore"],
|
|
210
214
|
});
|
|
@@ -271,13 +275,28 @@ function currentHumanDecision(store, decisionId) {
|
|
|
271
275
|
}
|
|
272
276
|
function directDecisionCommit(store, root, decisionId) {
|
|
273
277
|
const decision = currentHumanDecision(store, decisionId);
|
|
274
|
-
const
|
|
275
|
-
if (!
|
|
278
|
+
const sha = replacementFreeExactCommit(root, decision.commit);
|
|
279
|
+
if (!sha || !FULL_SHA.test(sha))
|
|
276
280
|
throw new Error(`G2 behavior decision ${decisionId} fixing commit is unavailable`);
|
|
281
|
+
let subject;
|
|
282
|
+
let date;
|
|
283
|
+
try {
|
|
284
|
+
const raw = execFileSync("git", ["-C", root, "show", "-s", "--format=%s%x00%aI", sha], {
|
|
285
|
+
encoding: "utf8",
|
|
286
|
+
env: replacementFreeGitEnvironment(),
|
|
287
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
288
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
289
|
+
}).trimEnd();
|
|
290
|
+
[subject = "", date = ""] = raw.split("\0");
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
throw new Error(`G2 behavior decision ${decisionId} fixing commit metadata is unavailable`);
|
|
294
|
+
}
|
|
277
295
|
let knownBad;
|
|
278
296
|
try {
|
|
279
|
-
knownBad = execFileSync("git", ["-C", root, "rev-parse", `${
|
|
297
|
+
knownBad = execFileSync("git", ["-C", root, "rev-parse", `${sha}^`], {
|
|
280
298
|
encoding: "utf8",
|
|
299
|
+
env: replacementFreeGitEnvironment(),
|
|
281
300
|
stdio: ["ignore", "pipe", "ignore"],
|
|
282
301
|
}).trim();
|
|
283
302
|
}
|
|
@@ -287,10 +306,10 @@ function directDecisionCommit(store, root, decisionId) {
|
|
|
287
306
|
if (!FULL_SHA.test(knownBad))
|
|
288
307
|
throw new Error(`G2 behavior decision ${decisionId} first parent is invalid`);
|
|
289
308
|
return {
|
|
290
|
-
commit:
|
|
291
|
-
subject
|
|
292
|
-
date
|
|
293
|
-
changedFiles: new Set(
|
|
309
|
+
commit: sha,
|
|
310
|
+
subject,
|
|
311
|
+
date,
|
|
312
|
+
changedFiles: new Set(replacementFreeCommitFiles(root, sha)),
|
|
294
313
|
decisionId: decision.id,
|
|
295
314
|
knownBad,
|
|
296
315
|
groundingHash: canonicalHash(decision),
|
|
@@ -360,7 +379,7 @@ function directDecisionReview(store, root, opts, behaviorResolutions, since, max
|
|
|
360
379
|
if (resolution)
|
|
361
380
|
candidate.human_review = resolution;
|
|
362
381
|
}
|
|
363
|
-
candidates.sort((left, right) => left.test.file
|
|
382
|
+
candidates.sort((left, right) => compareCodeUnits(left.test.file, right.test.file) || compareCodeUnits(left.test.name, right.test.name));
|
|
364
383
|
const items = candidates.slice(0, limit);
|
|
365
384
|
const reviewCounts = candidates.some((candidate) => candidate.human_review !== undefined) ? {
|
|
366
385
|
selected_candidates: candidates.filter((candidate) => candidate.human_review?.disposition === "selected").length,
|
|
@@ -425,9 +444,9 @@ export function buildG2BehaviorCandidateReview(store, root, opts = {}, resolutio
|
|
|
425
444
|
const candidates = [];
|
|
426
445
|
const failures = [];
|
|
427
446
|
const withoutTests = [];
|
|
428
|
-
for (const commit of [...commits.values()].sort((left, right) => left.commit
|
|
447
|
+
for (const commit of [...commits.values()].sort((left, right) => compareCodeUnits(left.commit, right.commit))) {
|
|
429
448
|
let found = false;
|
|
430
|
-
for (const file of [...commit.changedFiles].filter(safeTestFile).sort()) {
|
|
449
|
+
for (const file of [...commit.changedFiles].filter(safeTestFile).sort(compareCodeUnits)) {
|
|
431
450
|
const after = fileAt(root, commit.commit, file);
|
|
432
451
|
if (after == null) {
|
|
433
452
|
failures.push({ commit: commit.commit, file, error: "known-good test source unavailable" });
|
|
@@ -447,9 +466,9 @@ export function buildG2BehaviorCandidateReview(store, root, opts = {}, resolutio
|
|
|
447
466
|
statement: name,
|
|
448
467
|
test: { file, name, source_hash: sourceHash },
|
|
449
468
|
runner: runnerFor(file, name),
|
|
450
|
-
decision_ids: [...commit.decisionIds].sort(),
|
|
451
|
-
source_candidate_ids: [...commit.sourceCandidateIds].sort(),
|
|
452
|
-
source_attestation_ids: [...commit.sourceAttestationIds].sort(),
|
|
469
|
+
decision_ids: [...commit.decisionIds].sort(compareCodeUnits),
|
|
470
|
+
source_candidate_ids: [...commit.sourceCandidateIds].sort(compareCodeUnits),
|
|
471
|
+
source_attestation_ids: [...commit.sourceAttestationIds].sort(compareCodeUnits),
|
|
453
472
|
proposed_corpus: {
|
|
454
473
|
known_bad: { ref: commit.knownBad, expected: "failed" },
|
|
455
474
|
known_good: { ref: commit.commit, expected: "passed" },
|
|
@@ -471,10 +490,10 @@ export function buildG2BehaviorCandidateReview(store, root, opts = {}, resolutio
|
|
|
471
490
|
if (resolution)
|
|
472
491
|
candidate.human_review = resolution;
|
|
473
492
|
}
|
|
474
|
-
candidates.sort((left, right) => right.commit_date
|
|
475
|
-
|| left.commit
|
|
476
|
-
|| left.test.file
|
|
477
|
-
|| left.test.name
|
|
493
|
+
candidates.sort((left, right) => compareCodeUnits(right.commit_date, left.commit_date)
|
|
494
|
+
|| compareCodeUnits(left.commit, right.commit)
|
|
495
|
+
|| compareCodeUnits(left.test.file, right.test.file)
|
|
496
|
+
|| compareCodeUnits(left.test.name, right.test.name));
|
|
478
497
|
const items = candidates.slice(0, limit);
|
|
479
498
|
const reviewCounts = candidates.some((candidate) => candidate.human_review !== undefined) ? {
|
|
480
499
|
selected_candidates: candidates.filter((candidate) => candidate.human_review?.disposition === "selected").length,
|
|
@@ -490,8 +509,8 @@ export function buildG2BehaviorCandidateReview(store, root, opts = {}, resolutio
|
|
|
490
509
|
candidate_commits: commits.size,
|
|
491
510
|
behavior_candidates: candidates.length,
|
|
492
511
|
...reviewCounts,
|
|
493
|
-
commits_without_added_tests: withoutTests.sort(),
|
|
494
|
-
extraction_failures: failures.sort((left, right) => left.commit
|
|
512
|
+
commits_without_added_tests: withoutTests.sort(compareCodeUnits),
|
|
513
|
+
extraction_failures: failures.sort((left, right) => compareCodeUnits(left.commit, right.commit) || compareCodeUnits(left.file, right.file)),
|
|
495
514
|
items,
|
|
496
515
|
has_more: candidates.length > items.length,
|
|
497
516
|
limitations: LIMITATIONS,
|
|
@@ -523,12 +542,16 @@ export function nodeTestInfrastructureError(output) {
|
|
|
523
542
|
return null;
|
|
524
543
|
}
|
|
525
544
|
function runLeg(root, session, hooks, env, candidate, commit, expected, source, budgetMs) {
|
|
526
|
-
if (!FULL_SHA.test(commit) ||
|
|
545
|
+
if (!FULL_SHA.test(commit) || replacementFreeExactCommit(root, commit) !== commit)
|
|
527
546
|
return errorLeg(commit, expected, "commit-ref-unresolved");
|
|
528
547
|
const run = mkdtempSync(join(session, `${expected}-`));
|
|
529
548
|
const checkout = join(run, "checkout");
|
|
530
549
|
const dependencySnapshot = dependencySnapshotForCommit(root, commit);
|
|
531
550
|
const dependencySnapshotId = dependencySnapshot?.snapshot.id;
|
|
551
|
+
if (hasUnsafeCheckoutAttributes(root, commit, env, { allowDisabledLfs: true })) {
|
|
552
|
+
rmSync(run, { recursive: true, force: true });
|
|
553
|
+
return errorLeg(commit, expected, "unsafe-checkout-attributes", dependencySnapshotId);
|
|
554
|
+
}
|
|
532
555
|
let added = false;
|
|
533
556
|
let leg;
|
|
534
557
|
try {
|
|
@@ -539,7 +562,7 @@ function runLeg(root, session, hooks, env, candidate, commit, expected, source,
|
|
|
539
562
|
});
|
|
540
563
|
added = true;
|
|
541
564
|
if (dependencySnapshot) {
|
|
542
|
-
|
|
565
|
+
materializeDependencySnapshot(dependencySnapshot, join(checkout, "node_modules"));
|
|
543
566
|
}
|
|
544
567
|
const testFile = join(checkout, candidate.test.file);
|
|
545
568
|
mkdirSync(dirname(testFile), { recursive: true });
|
|
@@ -565,7 +588,7 @@ function runLeg(root, session, hooks, env, candidate, commit, expected, source,
|
|
|
565
588
|
args = testArgs;
|
|
566
589
|
}
|
|
567
590
|
else {
|
|
568
|
-
const tsx = dependencySnapshot ? join(
|
|
591
|
+
const tsx = dependencySnapshot ? join(checkout, "node_modules", "tsx", "dist", "cli.mjs") : "";
|
|
569
592
|
args = existsSync(tsx) ? [tsx, ...testArgs] : null;
|
|
570
593
|
}
|
|
571
594
|
if (!args) {
|