@davesheffer/hunch 1.45.2 → 1.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/cli/index.js +139 -32
  2. package/dist/cli/invocation.d.ts +14 -0
  3. package/dist/cli/invocation.js +23 -0
  4. package/dist/cli/verify.d.ts +28 -0
  5. package/dist/cli/verify.js +265 -0
  6. package/dist/constitution/experiment.d.ts +1 -1
  7. package/dist/core/agenthook.js +26 -6
  8. package/dist/core/boundedSpawn.d.ts +57 -0
  9. package/dist/core/boundedSpawn.js +321 -0
  10. package/dist/core/config.d.ts +6 -0
  11. package/dist/core/config.js +1 -0
  12. package/dist/core/delivery.d.ts +6 -1
  13. package/dist/core/delivery.js +71 -5
  14. package/dist/core/migrate.d.ts +10 -1
  15. package/dist/core/migrate.js +17 -6
  16. package/dist/core/projectDna.js +11 -1
  17. package/dist/core/relativeImports.js +6 -2
  18. package/dist/core/taskSelection.js +48 -6
  19. package/dist/core/testSelection.d.ts +122 -0
  20. package/dist/core/testSelection.js +588 -0
  21. package/dist/extractors/git.js +32 -31
  22. package/dist/extractors/indexer.js +42 -6
  23. package/dist/extractors/languages.d.ts +9 -0
  24. package/dist/extractors/languages.js +28 -0
  25. package/dist/extractors/parse.d.ts +20 -0
  26. package/dist/extractors/parse.js +81 -5
  27. package/dist/extractors/workspaces.js +3 -1
  28. package/dist/integrations/health.d.ts +6 -0
  29. package/dist/integrations/health.js +33 -12
  30. package/dist/integrations/hooks.d.ts +4 -2
  31. package/dist/integrations/hooks.js +12 -2
  32. package/dist/integrations/mergeDriver.js +5 -3
  33. package/dist/integrations/optout.d.ts +3 -0
  34. package/dist/integrations/optout.js +17 -0
  35. package/dist/integrations/providers.d.ts +6 -2
  36. package/dist/integrations/providers.js +19 -2
  37. package/dist/integrations/team.js +4 -2
  38. package/dist/integrations/uninstall.d.ts +10 -4
  39. package/dist/integrations/uninstall.js +22 -18
  40. package/dist/mcp/server.js +47 -8
  41. package/dist/serve/app.js +9 -1
  42. package/dist/serve/config.js +5 -0
  43. package/dist/serve/writelock.d.ts +3 -1
  44. package/dist/serve/writelock.js +17 -3
  45. package/dist/store/hunchStore.d.ts +2 -1
  46. package/dist/store/hunchStore.js +8 -5
  47. package/dist/store/jsonStore.d.ts +13 -3
  48. package/dist/store/jsonStore.js +37 -13
  49. package/dist/store/stateBinding.js +17 -4
  50. package/dist/synthesis/provider.js +26 -78
  51. package/dist/synthesis/synthesize.js +1 -1
  52. package/package.json +2 -2
  53. package/server.json +2 -2
package/dist/cli/index.js CHANGED
@@ -22,13 +22,16 @@ import { fileURLToPath } from "node:url";
22
22
  import { Command } from "commander";
23
23
  import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget, isDir, realpathNorm, repoRelativeTarget } from "../core/paths.js";
24
24
  import { writeFileAtomic } from "../core/io.js";
25
+ import { boundedSpawnSync } from "../core/boundedSpawn.js";
25
26
  import { looksLikeCorrection, CORRECTION_NUDGE } from "../core/correction.js";
26
27
  import { HUNCH_VERSION } from "../core/version.js";
27
28
  import { registerIntegrationCommands } from "./integrations.js";
28
29
  import { registerTaskReportCommands } from "./taskReport.js";
29
30
  import { registerStateCommands } from "./state.js";
30
31
  import { DEFAULT_CONFIG as SERVE_DEFAULT_CONFIG_FILE, registerServeCommands } from "./serve.js";
32
+ import { readServeConfig } from "../serve/config.js";
31
33
  import { registerUpdateCommand } from "./update.js";
34
+ import { registerVerifyCommand } from "./verify.js";
32
35
  import { registerReviewMemoryCommands } from "./reviewMemory.js";
33
36
  import { detectInitiator, normalizeInitiator } from "../synthesis/initiator.js";
34
37
  import { inspectIntegrations, formatIntegrationHealth, integrationHealthFails, integrationSessionWarning } from "../integrations/health.js";
@@ -62,7 +65,7 @@ import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, share
62
65
  import { installMergeDriver } from "../integrations/mergeDriver.js";
63
66
  import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS, upgradeManagedGitignore, describeGitignoreUpgrade, memoryIgnoreRule, describeMemoryIgnoreRule } from "../integrations/gitignore.js";
64
67
  import { writeCiWorkflow } from "../integrations/ciAction.js";
65
- import { applyUninstall, formatUninstallPlan, isOptedOut, OPT_OUT_FILE, optedOutAt, optOutMessage, planUninstall } from "../integrations/uninstall.js";
68
+ import { applyUninstall, formatUninstallPlan, isOptedOut, OPT_OUT_FILE, optOutMarkerAt, optOutMessage, planUninstall } from "../integrations/uninstall.js";
66
69
  import { updateClaudeMd, renderHunchSection } from "../integrations/claudemd.js";
67
70
  import { HOOK_REMINDER } from "../core/hookText.js";
68
71
  import { measureFootprint } from "../core/footprint.js";
@@ -188,6 +191,7 @@ registerTaskReportCommands(program, () => { const { store, root } = storeFor();
188
191
  registerServeCommands(program);
189
192
  registerStateCommands(program);
190
193
  registerUpdateCommand(program);
194
+ registerVerifyCommand(program);
191
195
  registerReviewMemoryCommands(program, (records, repository, privateOnly) => {
192
196
  const { store, root } = storeFor();
193
197
  if (!repositoryUsesRemote(root, `https://github.com/${repository}.git`)) {
@@ -228,6 +232,103 @@ registerReviewMemoryCommands(program, (records, repository, privateOnly) => {
228
232
  // run: a leftover driver that exits without writing %A would let git drop `theirs`.
229
233
  const OPT_OUT_ALLOWED = new Set(["uninstall", "doctor", "help", "merge-driver", "merge-driver-grounding"]);
230
234
  const OPT_OUT_QUIET = new Set(["hook", "sync", "check", "grounding", "repair-provenance", "workspaces"]);
235
+ /** Locations a command writes to (#488), keyed by command path: an opted-out repository
236
+ * must never receive them, so their repository is gated IN ADDITION to the cwd's.
237
+ * `base` is what the command itself resolves a relative value against: the cwd's
238
+ * repository root (`findRoot`), Git's top level (`verify`), or the cwd; `join` marks a
239
+ * command that joins the value onto that base even when absolute (`export-adr`).
240
+ * `checkout` marks a command that also writes into the MAIN checkout (the overlay
241
+ * anchor) and the git common dir (the shared-overlay pointer, the shared git hooks),
242
+ * so both are gated; with no target (`init`) only those are. */
243
+ const OPT_OUT_OUTPUTS = {
244
+ "init": { base: "root", checkout: true },
245
+ "private": { arg: 0, base: "root", checkout: true },
246
+ "shared": { arg: 0, base: "root", checkout: true },
247
+ "worktree": { arg: 0, base: "root", checkout: true },
248
+ "export-adr": { arg: 0, base: "root", join: true },
249
+ "review-memory auto": { option: "output", base: "cwd" },
250
+ "verify": { option: "map", base: "toplevel" },
251
+ };
252
+ /** Where `hunch private`/`shared` anchor the default overlay store: the MAIN worktree
253
+ * root, shared by the write and the opt-out gate so they cannot diverge. */
254
+ function overlayAnchor(root) {
255
+ return mainWorktreeRoot(root);
256
+ }
257
+ /** Git's top level for the cwd, as `hunch verify` resolves it; findRoot() outside Git. */
258
+ function gitToplevel() {
259
+ try {
260
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }).trim() || findRoot();
261
+ }
262
+ catch {
263
+ return findRoot();
264
+ }
265
+ }
266
+ /** The marker gating `path` (the cwd, or a location a command writes to). Its own store
267
+ * or repository root (`findRoot`: git-less stores included) counts first; inside a
268
+ * repository the walk then passes any nested `.hunch` store up to the repository
269
+ * (#488). A stray marker in a non-store, non-repository ancestor (e.g. ~) gates nothing. */
270
+ function markerFor(path) {
271
+ const real = realpathNorm(path);
272
+ const root = findRoot(real);
273
+ if (isOptedOut(root))
274
+ return join(root, OPT_OUT_FILE);
275
+ return optOutMarkerAt(real, { withinRepo: true });
276
+ }
277
+ /** Every location whose repository the opt-out gate judges for this invocation (#458,
278
+ * #488). A `--root` or a `serve` config names the repository the command acts on, so
279
+ * it REPLACES the cwd; `serve` also gates every partition root its config names (an
280
+ * unreadable config gates its directory only; the command itself then fails). An
281
+ * output location only ADDS to the cwd. */
282
+ function optOutTargets(top, action) {
283
+ const path = [];
284
+ for (let c = action; c && c !== program; c = c.parent)
285
+ path.unshift(c.name());
286
+ const given = (v) => typeof v === "string" && v.trim() !== "";
287
+ const actionOpts = action.opts();
288
+ const targets = [];
289
+ let cwd = true;
290
+ if (given(actionOpts.root)) {
291
+ targets.push(resolve(actionOpts.root));
292
+ cwd = false;
293
+ }
294
+ if (top.name() === "serve" && (path[1] === "init" || cwd)) {
295
+ // `serve` and `serve init` both take --config; the command reads the parent's first.
296
+ const configOpt = top.opts().config ?? actionOpts.config;
297
+ const configFile = resolve(given(configOpt) ? configOpt : SERVE_DEFAULT_CONFIG_FILE);
298
+ targets.push(dirname(configFile));
299
+ if (cwd) {
300
+ try {
301
+ for (const p of readServeConfig(configFile).partitions)
302
+ targets.push(p.root);
303
+ }
304
+ catch { /* the command reports it */ }
305
+ }
306
+ cwd = false;
307
+ }
308
+ // Output locations follow the cwd rule: a marker gates them only inside a repository,
309
+ // never from a non-repository ancestor (bare / --separate-git-dir common dirs included).
310
+ const outputs = [];
311
+ const out = OPT_OUT_OUTPUTS[path.join(" ")];
312
+ if (out) {
313
+ const value = out.option !== undefined ? actionOpts[out.option] : action.processedArgs[out.arg ?? 0];
314
+ if (given(value)) {
315
+ const base = out.base === "root" ? findRoot() : out.base === "toplevel" ? gitToplevel() : process.cwd();
316
+ // `join`: the path the command writes, plus the absolute path the user named.
317
+ if (out.join)
318
+ outputs.push(join(base, toPosixTarget(value)));
319
+ outputs.push(resolve(base, value));
320
+ }
321
+ if (out.checkout) {
322
+ const root = findRoot();
323
+ const common = gitCommonDir(root);
324
+ // Only a real linked worktree shares a common dir with a main checkout; a submodule's
325
+ // common dir (super/.git/modules/x) must not pull the superproject's marker in.
326
+ if (common && mainWorktreeRoot(root) !== root)
327
+ outputs.push(overlayAnchor(root), resolve(root, common));
328
+ }
329
+ }
330
+ return { targets, outputs, cwd };
331
+ }
231
332
  program.hook("preAction", (_thisCommand, actionCommand) => {
232
333
  let top = actionCommand;
233
334
  while (top.parent && top.parent !== program)
@@ -235,23 +336,26 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
235
336
  const name = top.name();
236
337
  if (OPT_OUT_ALLOWED.has(name))
237
338
  return;
238
- // Gate the repository the command acts on (#458): a `--root` names it (`hunch mcp
239
- // --root`, `hunch serve ... --root`), so its real path is gated with every ancestor
240
- // up to its store or repository and the cwd's repository is irrelevant; without
241
- // one, the command acts on the cwd's repository. `serve init` also writes its
242
- // config file (resolved against the cwd), so the directory it lands in gates it too.
243
- const rootOpt = actionCommand.opts().root;
244
- const rootGiven = typeof rootOpt === "string" && rootOpt.trim() !== "";
245
- let gated = rootGiven ? optedOutAt(realpathNorm(resolve(rootOpt))) : isOptedOut(findRoot());
246
- if (!gated && name === "serve" && actionCommand.name() === "init") {
247
- const configOpt = top.opts().config ?? actionCommand.opts().config;
248
- const configFile = resolve(typeof configOpt === "string" ? configOpt : SERVE_DEFAULT_CONFIG_FILE);
249
- gated = optedOutAt(realpathNorm(dirname(configFile)));
250
- }
251
- if (!gated)
339
+ // Gate the repository each location acts on (#458, #488): its real path is walked up
340
+ // through its ancestors to its repository, past any nested `.hunch` store (fail
341
+ // closed, so a cwd inside a nested store is gated too), and the refusal names the
342
+ // marker that actually gates it.
343
+ const { targets, outputs, cwd } = optOutTargets(top, actionCommand);
344
+ let marker = cwd ? markerFor(process.cwd()) : null;
345
+ for (const target of targets) {
346
+ if (marker)
347
+ break;
348
+ marker = optOutMarkerAt(realpathNorm(target));
349
+ }
350
+ for (const target of outputs) {
351
+ if (marker)
352
+ break;
353
+ marker = markerFor(target);
354
+ }
355
+ if (!marker)
252
356
  return;
253
357
  if (name !== "hook")
254
- console.error(optOutMessage());
358
+ console.error(optOutMessage(marker));
255
359
  process.exit(OPT_OUT_QUIET.has(name) ? 0 : 1);
256
360
  });
257
361
  // Read an already-known update and schedule any registry refresh in a detached
@@ -553,8 +657,8 @@ program
553
657
  }
554
658
  // Windows self-heal: if an earlier global `claude mcp add` left a drive-letter
555
659
  // case-split in ~/.claude.json, merge it so hunch resolves under either casing.
556
- // No-op (silent) off Windows.
557
- reportClaudeConfigHeal();
660
+ // No-op (silent) off Windows; the no-op line belongs to --details (#493).
661
+ reportClaudeConfigHeal(say);
558
662
  // Worktree-seamless: register any configured overlay at the git common dir so EVERY
559
663
  // worktree of this repo auto-discovers it (also backfills pre-0.32 single-worktree setups),
560
664
  // and note when we're initializing inside a linked worktree (memory is shared, not separate).
@@ -985,13 +1089,12 @@ function discoverSharedRemote(remoteUrl) {
985
1089
  && remoteUrl.startsWith(prefix))
986
1090
  return null;
987
1091
  }
988
- const listed = spawnSync("git", [
1092
+ // Bounded so a timeout kills git's whole tree, not just the root (#498).
1093
+ const listed = boundedSpawnSync("git", [
989
1094
  "-C", probe,
990
1095
  "-c", `core.hooksPath=${hooksDir}`,
991
1096
  "ls-remote", "--refs", "--heads", "--upload-pack=git-upload-pack", remoteUrl,
992
1097
  ], {
993
- encoding: "utf8",
994
- stdio: ["ignore", "pipe", "ignore"],
995
1098
  env: boundedTeamGitEnv(),
996
1099
  timeout: 15_000,
997
1100
  });
@@ -1324,7 +1427,7 @@ function configureOverlay(dir, opts, mode) {
1324
1427
  // Anchor the default store at the MAIN worktree root: a linked worktree can be
1325
1428
  // `git worktree remove`d, which would take the store (and every other worktree's
1326
1429
  // absolute pointer to it) down with it. An explicit [dir] still resolves from here.
1327
- const anchor = mainWorktreeRoot(root);
1430
+ const anchor = overlayAnchor(root);
1328
1431
  if (opts.repo) {
1329
1432
  const dest = join(anchor, ".hunch-private");
1330
1433
  // Canonicalize once in the invocation context so clone, attach, fetch, and
@@ -6094,12 +6197,15 @@ program
6094
6197
  return;
6095
6198
  }
6096
6199
  const res = target.persistMigration();
6097
- writeManifest(paths, SCHEMA_VERSION);
6200
+ writeManifest(paths, res.version); // held at `from` while any record failed to migrate
6098
6201
  store.reindex();
6099
- pumpMemoryHome(store, root, store.unified ? "private" : "public", `hunch: migrate schema v${from} to v${SCHEMA_VERSION}`);
6100
- console.log(`✓ Migrated v${from} → v${SCHEMA_VERSION}: ${res.migrated} record(s) upgraded.`);
6202
+ pumpMemoryHome(store, root, store.unified ? "private" : "public", `hunch: migrate schema v${from} to v${res.version}`);
6203
+ console.log(`✓ Migrated v${from} → v${res.version}: ${res.migrated} record(s) upgraded.`);
6101
6204
  if (res.skipped) {
6102
- console.warn(`⚠ ${res.skipped} record(s) could NOT be migrated and will no longer load. They are preserved on disk in their old shape under .hunch/ for manual recovery.`);
6205
+ console.warn(`⚠ ${res.skipped} record(s) could not be migrated. They are preserved on disk in their old shape under .hunch/ for manual recovery; the schema stays at v${res.version}, so re-running \`hunch migrate\` after a fix completes the migration to v${SCHEMA_VERSION}.`);
6206
+ }
6207
+ if (res.invalid) {
6208
+ console.warn(`⚠ ${res.invalid} record(s) are invalid or unreadable and no pending migration applies to them. They were left untouched under .hunch/; fix them by hand.`);
6103
6209
  }
6104
6210
  store.close();
6105
6211
  });
@@ -7610,26 +7716,27 @@ function rel(root, p) {
7610
7716
  }
7611
7717
  /** Run the Windows ~/.claude.json drive-letter case-split heal and print what it
7612
7718
  * did. Silent + no-op off Windows. A parse refusal is surfaced as a warning, never
7613
- * thrown out of doctor/init (those commands must still complete). */
7614
- function reportClaudeConfigHeal() {
7719
+ * thrown out of doctor/init (those commands must still complete). `say` lets `init`
7720
+ * route the lines into its buffered output (shown with --details; warnings always). */
7721
+ function reportClaudeConfigHeal(say = (text) => console.log(text)) {
7615
7722
  let res;
7616
7723
  try {
7617
7724
  res = healClaudeConfigCaseSplit();
7618
7725
  }
7619
7726
  catch (e) {
7620
- console.log(` ⚠ Claude config: ${e.message}`);
7727
+ say(` ⚠ Claude config: ${e.message}`);
7621
7728
  return;
7622
7729
  }
7623
7730
  if (!res.applicable)
7624
7731
  return; // non-Windows: the case-split bug can't occur
7625
7732
  if (!res.changed) {
7626
- console.log(dim(`Claude config: no drive-letter project split (${res.file})`));
7733
+ say(dim(`Claude config: no drive-letter project split (${res.file})`));
7627
7734
  return;
7628
7735
  }
7629
7736
  for (const g of res.groups) {
7630
- console.log(`✓ healed Claude Code project case-split: mirrored [${g.servers.join(", ")}] across ${g.casings.join(" · ")}`);
7737
+ say(`✓ healed Claude Code project case-split: mirrored [${g.servers.join(", ")}] across ${g.casings.join(" · ")}`);
7631
7738
  }
7632
- console.log(dim(` ↳ backup: ${res.backup}`));
7739
+ say(dim(` ↳ backup: ${res.backup}`));
7633
7740
  }
7634
7741
  function fail(msg) {
7635
7742
  console.error(`error: ${msg}`);
@@ -41,3 +41,17 @@ export declare function classifyEntry(entry: string): {
41
41
  installed: boolean;
42
42
  };
43
43
  export declare function resolveInvocation(): ResolvedInvocation;
44
+ /** The command prefix for the git merge drivers. git runs a driver with the
45
+ * repository being merged as its cwd, so the dev-mode `npx tsx <entry>` would
46
+ * resolve tsx from THAT repo (or fetch it from the registry), not from Hunch's
47
+ * own checkout (#492). In dev mode, launch the absolute node binary against
48
+ * this checkout's tsx cli.mjs, the same shape selfBuildInvocation gives the
49
+ * git hooks (src/integrations/hooks.ts); every other invocation (published,
50
+ * dist, or a caller-supplied one) passes through unchanged. */
51
+ export declare function mergeDriverShell(invShell: string): string;
52
+ /** Filesystem paths, never a file URL: git expands `%`-escapes in the driver
53
+ * command, so a percent-encoded URL (any non-ASCII checkout path) would be
54
+ * corrupted. POSIX single quotes keep `$`, backticks and `"` literal under the
55
+ * /bin/sh git runs drivers with on every platform, and a literal `%` in a path
56
+ * is doubled so git expands it back to one. */
57
+ export declare function devMergeDriverShell(execPath: string, tsxCli: string, entry: string): string;
@@ -125,4 +125,27 @@ export function resolveInvocation() {
125
125
  installed,
126
126
  };
127
127
  }
128
+ /** The command prefix for the git merge drivers. git runs a driver with the
129
+ * repository being merged as its cwd, so the dev-mode `npx tsx <entry>` would
130
+ * resolve tsx from THAT repo (or fetch it from the registry), not from Hunch's
131
+ * own checkout (#492). In dev mode, launch the absolute node binary against
132
+ * this checkout's tsx cli.mjs, the same shape selfBuildInvocation gives the
133
+ * git hooks (src/integrations/hooks.ts); every other invocation (published,
134
+ * dist, or a caller-supplied one) passes through unchanged. */
135
+ export function mergeDriverShell(invShell) {
136
+ const inv = resolveInvocation();
137
+ if (inv.installed || !inv.shell.startsWith("npx tsx ") || invShell !== inv.shell)
138
+ return invShell;
139
+ const entry = fileURLToPath(import.meta.url).replace(/invocation\.ts$/, "index.ts");
140
+ return devMergeDriverShell(process.execPath, fileURLToPath(import.meta.resolve("tsx/cli")), entry);
141
+ }
142
+ /** Filesystem paths, never a file URL: git expands `%`-escapes in the driver
143
+ * command, so a percent-encoded URL (any non-ASCII checkout path) would be
144
+ * corrupted. POSIX single quotes keep `$`, backticks and `"` literal under the
145
+ * /bin/sh git runs drivers with on every platform, and a literal `%` in a path
146
+ * is doubled so git expands it back to one. */
147
+ export function devMergeDriverShell(execPath, tsxCli, entry) {
148
+ const quote = (s) => `'${s.replace(/'/g, "'\\''")}'`.replace(/%/g, "%%");
149
+ return [execPath, tsxCli, entry].map(quote).join(" ");
150
+ }
128
151
  //# sourceMappingURL=invocation.js.map
@@ -0,0 +1,28 @@
1
+ import type { Command } from "commander";
2
+ import { type ImpactMap } from "../core/testSelection.js";
3
+ export declare const DEFAULT_IMPACT_MAP = ".hunch-cache/test-impact.json";
4
+ /** Split a command template into argv tokens; double/single quotes group. */
5
+ export declare function tokenize(template: string): string[];
6
+ /** The runner argv (never a shell). A configured template wins (`{files}` expands
7
+ * to one argv entry per file); otherwise this repo's runner when present, else
8
+ * `npm test` (full) / `npm test -- <files>`. */
9
+ export declare function runnerArgv(root: string, template: string | undefined, files: string[], extra: string[], full: boolean): string[];
10
+ /** Run each test in its own runner process under NODE_V8_COVERAGE (inherited
11
+ * by spawned CLI/MCP children) and record the repo files each executed. An
12
+ * entry is `complete` only when the test file passed and produced coverage;
13
+ * failing tests still record their (partial) files. `only` merges into the
14
+ * old map, keeping each untouched entry's own commit stamp. */
15
+ export declare function refreshImpactMap(opts: {
16
+ root: string;
17
+ tests: string[];
18
+ files: string[];
19
+ template?: string;
20
+ mapPath: string;
21
+ only?: string[];
22
+ concurrency?: number;
23
+ log?: (line: string) => void;
24
+ }): Promise<{
25
+ map: ImpactMap;
26
+ failed: string[];
27
+ }>;
28
+ export declare function registerVerifyCommand(program: Command): void;
@@ -0,0 +1,265 @@
1
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
3
+ import { availableParallelism, tmpdir } from "node:os";
4
+ import { dirname, isAbsolute, join, relative } from "node:path";
5
+ import { readConfig } from "../core/config.js";
6
+ import { writeFileAtomic } from "../core/io.js";
7
+ import { hunchPaths } from "../core/paths.js";
8
+ import { buildImportGraph, changedFiles, coverageFiles, discoverTests, listRepoFiles, loadImpactMap, selectTests, testDependencyModel, } from "../core/testSelection.js";
9
+ export const DEFAULT_IMPACT_MAP = ".hunch-cache/test-impact.json";
10
+ function testScript(root) {
11
+ try {
12
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
13
+ return typeof pkg.scripts?.test === "string" ? pkg.scripts.test : null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ /** Split a command template into argv tokens; double/single quotes group. */
20
+ export function tokenize(template) {
21
+ const out = [];
22
+ for (const m of template.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g))
23
+ out.push(m[1] ?? m[2] ?? m[3]);
24
+ return out;
25
+ }
26
+ /** npm without a shell: run npm-cli.js under this Node; POSIX may exec `npm`. */
27
+ function npmArgv(args) {
28
+ const dir = dirname(process.execPath);
29
+ for (const cli of [join(dir, "node_modules/npm/bin/npm-cli.js"), join(dir, "../lib/node_modules/npm/bin/npm-cli.js")]) {
30
+ if (existsSync(cli))
31
+ return [process.execPath, cli, ...args];
32
+ }
33
+ if (process.platform === "win32")
34
+ throw new Error("cannot locate npm-cli.js; set verify.command in .hunch/config.json");
35
+ return ["npm", ...args];
36
+ }
37
+ /** The runner argv (never a shell). A configured template wins (`{files}` expands
38
+ * to one argv entry per file); otherwise this repo's runner when present, else
39
+ * `npm test` (full) / `npm test -- <files>`. */
40
+ export function runnerArgv(root, template, files, extra, full) {
41
+ if (template) {
42
+ const tokens = tokenize(template);
43
+ const argv = tokens.includes("{files}") ? tokens.flatMap((t) => t === "{files}" ? files : [t]) : [...tokens, ...files];
44
+ if (argv[0] === "node")
45
+ argv[0] = process.execPath;
46
+ return [...argv, ...extra];
47
+ }
48
+ if (full && testScript(root))
49
+ return npmArgv(["test", ...(extra.length ? ["--", ...extra] : [])]);
50
+ if (existsSync(join(root, "tooling/run-tests.mjs")))
51
+ return [process.execPath, "tooling/run-tests.mjs", ...files, ...extra];
52
+ return npmArgv(["test", "--", ...files, ...extra]);
53
+ }
54
+ const display = (argv) => argv.map((a, i) => i === 0 && a === process.execPath ? "node" : /\s/.test(a) ? JSON.stringify(a) : a).join(" ");
55
+ function printSelection(sel, total, note, hasMap) {
56
+ for (const line of note)
57
+ console.log(` note: ${line}`);
58
+ if (sel.mode === "none") {
59
+ console.log("✓ no tests selected — nothing the changes can affect.");
60
+ return;
61
+ }
62
+ if (sel.mode === "full") {
63
+ console.log(`▶ FULL suite (${total} tests): ${sel.fullReason}`);
64
+ if (sel.unattributed.length)
65
+ console.log(` unattributed: ${sel.unattributed.join(", ")}`);
66
+ // Only trusted map entries narrow spawning tests; triggers, non-code and unattributed changes stay FULL.
67
+ const spawnWide = /^selection too broad/.test(sel.fullReason ?? "") && Object.values(sel.reasons).some((r) => r.some((why) => why.includes("whole program")));
68
+ if (!hasMap && spawnWide)
69
+ console.log(" Run `hunch verify --refresh-map` once to enable narrow selection for CLI/MCP tests.");
70
+ return;
71
+ }
72
+ console.log(`▶ ${sel.tests.length} of ${total} tests selected:`);
73
+ for (const test of sel.tests)
74
+ console.log(` ${test} ← ${sel.reasons[test].join("; ")}`);
75
+ }
76
+ function headSha(root) {
77
+ try {
78
+ return execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }).trim();
79
+ }
80
+ catch {
81
+ return "";
82
+ }
83
+ }
84
+ function readCoverageDir(dir) {
85
+ if (!existsSync(dir))
86
+ return [];
87
+ const reports = [];
88
+ for (const name of readdirSync(dir)) {
89
+ if (!name.endsWith(".json"))
90
+ continue;
91
+ try {
92
+ reports.push(JSON.parse(readFileSync(join(dir, name), "utf8")));
93
+ }
94
+ catch { /* partial write → skip */ }
95
+ }
96
+ return reports;
97
+ }
98
+ /** Run each test in its own runner process under NODE_V8_COVERAGE (inherited
99
+ * by spawned CLI/MCP children) and record the repo files each executed. An
100
+ * entry is `complete` only when the test file passed and produced coverage;
101
+ * failing tests still record their (partial) files. `only` merges into the
102
+ * old map, keeping each untouched entry's own commit stamp. */
103
+ export async function refreshImpactMap(opts) {
104
+ const { root, mapPath } = opts;
105
+ const log = opts.log ?? console.log;
106
+ const run = opts.only?.length ? opts.only.map((t) => t.replace(/\\/g, "/").replace(/^\.\//, "")) : opts.tests;
107
+ let previous = null;
108
+ if (opts.only?.length) {
109
+ try {
110
+ previous = loadImpactMap(mapPath);
111
+ }
112
+ catch {
113
+ previous = null;
114
+ }
115
+ }
116
+ const repoFiles = new Set(opts.files);
117
+ const commit = headSha(root);
118
+ const { closureHash } = testDependencyModel(root, opts.files, buildImportGraph(root, opts.files));
119
+ const covDir = mkdtempSync(join(tmpdir(), "hunch-verify-cov-"));
120
+ const map = { ...(previous?.map ?? {}) };
121
+ const failed = [];
122
+ const width = Math.max(1, opts.concurrency ?? availableParallelism() - 1);
123
+ const env = { ...process.env };
124
+ delete env.NODE_TEST_CONTEXT; // a parent test runner must not turn the child into a reporter
125
+ let next = 0;
126
+ const worker = async () => {
127
+ while (next < run.length) {
128
+ const index = next++;
129
+ const test = run[index];
130
+ const dir = join(covDir, String(index));
131
+ const argv = runnerArgv(root, opts.template, [test], [], false);
132
+ const status = await new Promise((done) => {
133
+ const child = spawn(argv[0], argv.slice(1), { cwd: root, stdio: "ignore", windowsHide: true, env: { ...env, NODE_V8_COVERAGE: dir } });
134
+ child.on("error", () => done(1));
135
+ child.on("close", (code) => done(code ?? 1));
136
+ });
137
+ const reports = readCoverageDir(dir);
138
+ if (status !== 0)
139
+ failed.push(test);
140
+ const files = coverageFiles(reports, root, repoFiles).filter((f) => f !== test);
141
+ map[test] = { files, complete: status === 0 && reports.length > 0, commit, closureHash };
142
+ log(` ${status === 0 ? "✓" : "✗"} ${test} (${files.length} files)`);
143
+ }
144
+ };
145
+ try {
146
+ await Promise.all(Array.from({ length: Math.min(width, run.length) }, worker));
147
+ }
148
+ finally {
149
+ // Coverage is scratch; a straggling child holding a handle must not fail the refresh.
150
+ try {
151
+ rmSync(covDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
152
+ }
153
+ catch { /* best effort */ }
154
+ }
155
+ const sortedMap = {};
156
+ for (const test of Object.keys(map).sort())
157
+ sortedMap[test] = map[test];
158
+ const result = { version: 2, commit, generatedAt: new Date().toISOString(), closureHash, map: sortedMap };
159
+ mkdirSync(dirname(mapPath), { recursive: true });
160
+ writeFileAtomic(mapPath, JSON.stringify(result, null, 2) + "\n");
161
+ return { map: result, failed: failed.sort() };
162
+ }
163
+ export function registerVerifyCommand(program) {
164
+ program
165
+ .command("verify")
166
+ .description("Run only the tests the current change can affect (fail-closed: anything unattributed runs the full suite).")
167
+ .argument("[runnerArgs...]", "extra arguments for the test runner (after --)")
168
+ .option("--staged", "select from staged changes only")
169
+ .option("--base <ref>", "select from merge-base(<ref>, HEAD)..HEAD plus the working tree")
170
+ .option("--list", "print the selection with reasons; run nothing")
171
+ .option("--full", "run the full suite")
172
+ .option("--ci", "run the full suite (CI)")
173
+ .option("--json", "print the selection as JSON; run nothing")
174
+ .option("--map <path>", `test impact map (default ${DEFAULT_IMPACT_MAP})`)
175
+ .option("--refresh-map", "rebuild the impact map: run each test under V8 coverage")
176
+ .option("--only <tests...>", "with --refresh-map: refresh only these tests, merging into the existing map")
177
+ .action(async (runnerArgs, opts) => {
178
+ let root;
179
+ try {
180
+ root = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }).trim();
181
+ }
182
+ catch {
183
+ console.error("hunch verify must run inside a git repository");
184
+ process.exitCode = 1;
185
+ return;
186
+ }
187
+ const config = readConfig(hunchPaths(root)).verify ?? {};
188
+ const files = listRepoFiles(root);
189
+ const allTests = discoverTests(files, typeof config.tests === "string" ? config.tests : undefined);
190
+ const template = typeof config.command === "string" ? config.command : undefined;
191
+ const mapPath = opts.map ? (isAbsolute(opts.map) ? opts.map : join(root, opts.map)) : join(root, DEFAULT_IMPACT_MAP);
192
+ if (opts.refreshMap) {
193
+ const total = opts.only?.length ?? allTests.length;
194
+ console.log(`▶ refreshing impact map: ${total} test file(s) under V8 coverage`);
195
+ const { map, failed } = await refreshImpactMap({ root, tests: allTests, files, template, mapPath, only: opts.only });
196
+ const covered = new Set(Object.values(map.map).flatMap((e) => e.files)).size;
197
+ console.log(`✓ wrote ${opts.map ?? DEFAULT_IMPACT_MAP}: ${Object.keys(map.map).length} tests → ${covered} files @ ${map.commit.slice(0, 12) || "(no HEAD)"}`);
198
+ if (failed.length)
199
+ console.log(` ${failed.length} test file(s) failed (partial coverage recorded, not trusted): ${failed.join(", ")}`);
200
+ return;
201
+ }
202
+ const note = [];
203
+ let sel;
204
+ let hasMap = false;
205
+ if (opts.full || opts.ci) {
206
+ sel = { mode: "full", tests: allTests, reasons: {}, fullReason: opts.ci ? "--ci" : "--full", unattributed: [], mapUsed: [] };
207
+ }
208
+ else {
209
+ const mode = opts.base ? { kind: "base", ref: opts.base } : opts.staged ? { kind: "staged" } : { kind: "worktree" };
210
+ let impactMap;
211
+ try {
212
+ impactMap = loadImpactMap(mapPath) ?? undefined;
213
+ }
214
+ catch (e) {
215
+ note.push(`${e.message}; ignoring it`);
216
+ }
217
+ if (!impactMap && !note.length)
218
+ note.push(`no impact map at ${opts.map ?? DEFAULT_IMPACT_MAP}; using the import graph (build one: hunch verify --refresh-map)`);
219
+ const model = testDependencyModel(root, files, buildImportGraph(root, files));
220
+ // The map itself is a derived cache; a not-yet-ignored copy is never a code change.
221
+ const mapRel = relative(root, mapPath).replace(/\\/g, "/");
222
+ const changed = changedFiles(root, mode).filter((f) => f !== mapRel);
223
+ sel = selectTests({
224
+ changedFiles: changed, allTests, impactMap, importGraph: model.dependencies,
225
+ testText: model.testText, spawning: model.spawning, closureHash: model.closureHash,
226
+ wholeProgram: [...model.wholeProgram, ...changed.filter((f) => f.startsWith("src/"))], testNamers: model.testNamers,
227
+ });
228
+ hasMap = !!impactMap;
229
+ if (impactMap) {
230
+ const head = headSha(root);
231
+ const stale = sel.mapUsed.filter((t) => impactMap.map[t].commit !== head);
232
+ if (stale.length)
233
+ note.push(`${stale.length} impact-map entr${stale.length === 1 ? "y" : "ies"} used were recorded at another commit than HEAD ${head.slice(0, 12)} (${stale.join(", ")}); the map only adds tests`);
234
+ if (Object.values(impactMap.map).some((e) => e.closureHash !== model.closureHash)) {
235
+ note.push("CLI/MCP/tooling import closure changed since some entries were recorded; those spawning tests use the whole program");
236
+ }
237
+ }
238
+ }
239
+ if (opts.json) {
240
+ console.log(JSON.stringify({ ...sel, total: allTests.length }, null, 2));
241
+ return;
242
+ }
243
+ printSelection(sel, allTests.length, note, hasMap);
244
+ if (opts.list || sel.mode === "none")
245
+ return;
246
+ let argv;
247
+ try {
248
+ argv = runnerArgv(root, template, sel.tests, runnerArgs, sel.mode === "full");
249
+ }
250
+ catch (e) {
251
+ console.error(e.message);
252
+ process.exitCode = 1;
253
+ return;
254
+ }
255
+ console.log(`▶ running: ${display(argv)}\n`);
256
+ const run = spawnSync(argv[0], argv.slice(1), { cwd: root, stdio: "inherit", windowsHide: true });
257
+ if (run.error) {
258
+ console.error(run.error.message);
259
+ process.exitCode = 1;
260
+ return;
261
+ }
262
+ process.exitCode = run.status ?? 1;
263
+ });
264
+ }
265
+ //# sourceMappingURL=verify.js.map
@@ -237,8 +237,8 @@ export declare const ExperimentOutcomeSchema: z.ZodObject<{
237
237
  decision: z.ZodEnum<{
238
238
  rejected: "rejected";
239
239
  abandoned: "abandoned";
240
- uncompilable: "uncompilable";
241
240
  timeout: "timeout";
241
+ uncompilable: "uncompilable";
242
242
  cannot_decide: "cannot_decide";
243
243
  accepted_precise: "accepted_precise";
244
244
  accepted_edited: "accepted_edited";