@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.
Files changed (59) hide show
  1. package/README.md +96 -1
  2. package/dist/cli/index.js +1238 -396
  3. package/dist/constitution/adapters.js +31 -14
  4. package/dist/constitution/behaviorEvaluator.js +20 -7
  5. package/dist/constitution/behaviorProof.js +3 -2
  6. package/dist/constitution/canonical.js +7 -1
  7. package/dist/constitution/card.js +7 -2
  8. package/dist/constitution/compiler.js +71 -1
  9. package/dist/constitution/correctionPolicyMaterializer.js +496 -0
  10. package/dist/constitution/delta.js +3 -2
  11. package/dist/constitution/evaluator.js +29 -3
  12. package/dist/constitution/experiment.js +96 -5
  13. package/dist/constitution/experimentRunner.js +43 -14
  14. package/dist/constitution/g2BehaviorCandidates.js +49 -26
  15. package/dist/constitution/g2BehaviorDependencies.js +203 -14
  16. package/dist/constitution/g2Candidates.js +1 -1
  17. package/dist/constitution/lifecycle.js +17 -0
  18. package/dist/constitution/plan.js +26 -9
  19. package/dist/constitution/replacementFreeGit.js +67 -0
  20. package/dist/constitution/replay.js +6 -0
  21. package/dist/constitution/replayCache.js +1 -1
  22. package/dist/constitution/replayWorker.js +1 -1
  23. package/dist/constitution/repository.js +141 -5
  24. package/dist/constitution/safeCheckout.js +75 -0
  25. package/dist/constitution/schema.js +30 -5
  26. package/dist/constitution/service.js +74 -14
  27. package/dist/constitution/sourceMutation.js +65 -12
  28. package/dist/constitution/staticGraphBaseline.js +44 -0
  29. package/dist/constitution/structural.js +60 -4
  30. package/dist/core/autoreview.js +1 -1
  31. package/dist/core/canonicalOrder.js +6 -0
  32. package/dist/core/conformance.js +68 -27
  33. package/dist/core/docscan.js +2 -1
  34. package/dist/core/escalations.js +11 -0
  35. package/dist/core/io.js +44 -9
  36. package/dist/core/overlaySafety.js +178 -0
  37. package/dist/core/paths.js +13 -2
  38. package/dist/core/safeRepoFile.js +74 -0
  39. package/dist/extractors/comments.js +6 -8
  40. package/dist/extractors/git.js +1631 -82
  41. package/dist/extractors/indexer.js +86 -47
  42. package/dist/extractors/repoSource.js +390 -0
  43. package/dist/integrations/ciAction.js +10 -2
  44. package/dist/integrations/gitignore.js +44 -5
  45. package/dist/integrations/mergeDriver.js +23 -5
  46. package/dist/integrations/sync.js +61 -5
  47. package/dist/integrations/team.js +666 -23
  48. package/dist/mcp/server.js +261 -34
  49. package/dist/store/db.js +57 -7
  50. package/dist/store/hunchStore.js +92 -11
  51. package/dist/store/jsonStore.js +350 -63
  52. package/dist/store/schema.js +27 -11
  53. package/dist/synthesis/provider.js +13 -4
  54. package/dist/synthesis/synthesize.js +56 -19
  55. package/dist/wiki/graph.js +5 -4
  56. package/dist/wiki/wiki.js +16 -10
  57. package/package.json +15 -3
  58. package/tooling/competitive-watch.mjs +108 -0
  59. 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).localeCompare(rank(preregistration.assignment.seed, item.block, item.id, b)));
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.localeCompare(b))) {
291
- const ordered = [...blockCases].sort((a, b) => rank(preregistration.assignment.seed, block, a.id).localeCompare(rank(preregistration.assignment.seed, block, b.id)));
292
- const armOrder = [...arms].sort((a, b) => rank(preregistration.assignment.seed, block, "arm", a).localeCompare(rank(preregistration.assignment.seed, block, "arm", b)));
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).localeCompare(rank(preregistration.assignment.seed, "execution", b.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, symlinkSync, writeFileSync } from "node:fs";
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, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120_000 }).trim();
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 linkDependencies(source, cwd) {
190
+ function materializeDependencies(source, cwd) {
181
191
  const target = join(source, "node_modules");
182
- const link = join(cwd, "node_modules");
183
- if (!existsSync(target) || existsSync(link))
184
- return;
185
- symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir");
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 = git(bank.repository_root, ["rev-parse", bank.base_commit]);
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
- execFileSync("git", ["worktree", "add", "--detach", cwd, bank.base_commit], { cwd: bank.repository_root, stdio: ["ignore", "pipe", "pipe"], timeout: 120_000 });
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
- linkDependencies(bank.repository_root, cwd);
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, join(bank.repository_root, "node_modules"));
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], { cwd: bank.repository_root, stdio: "ignore", timeout: 120_000 });
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, symlinkSync, writeFileSync } from "node:fs";
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 meta = commitMeta(decision.commit, root);
275
- if (!meta)
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", `${meta.sha}^`], {
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: meta.sha,
291
- subject: meta.subject,
292
- date: meta.date,
293
- changedFiles: new Set(meta.files),
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.localeCompare(right.test.file) || left.test.name.localeCompare(right.test.name));
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.localeCompare(right.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.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));
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.localeCompare(right.commit) || left.file.localeCompare(right.file)),
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) || !revExists(commit, root))
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
- symlinkSync(dependencySnapshot.nodeModules, join(checkout, "node_modules"), process.platform === "win32" ? "junction" : "dir");
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(dependencySnapshot.nodeModules, "tsx", "dist", "cli.mjs") : "";
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) {