@davesheffer/hunch 1.45.2 → 1.47.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 (91) hide show
  1. package/dist/cli/index.js +290 -353
  2. package/dist/cli/invocation.d.ts +22 -13
  3. package/dist/cli/invocation.js +49 -29
  4. package/dist/cli/verify.d.ts +28 -0
  5. package/dist/cli/verify.js +265 -0
  6. package/dist/constitution/delta.js +2 -2
  7. package/dist/constitution/experiment.d.ts +1 -1
  8. package/dist/constitution/sourceMutation.js +28 -18
  9. package/dist/core/agenthook.js +26 -6
  10. package/dist/core/boundedSpawn.d.ts +57 -0
  11. package/dist/core/boundedSpawn.js +321 -0
  12. package/dist/core/commitrepair.d.ts +43 -138
  13. package/dist/core/commitrepair.js +76 -167
  14. package/dist/core/config.d.ts +6 -0
  15. package/dist/core/config.js +1 -0
  16. package/dist/core/correction.js +9 -9
  17. package/dist/core/delivery.d.ts +6 -1
  18. package/dist/core/delivery.js +71 -5
  19. package/dist/core/docanchors.js +9 -9
  20. package/dist/core/drift.js +22 -27
  21. package/dist/core/escalations.d.ts +16 -58
  22. package/dist/core/escalations.js +39 -121
  23. package/dist/core/glob.d.ts +7 -7
  24. package/dist/core/glob.js +12 -11
  25. package/dist/core/groundingLag.d.ts +2 -9
  26. package/dist/core/groundingLag.js +5 -11
  27. package/dist/core/groundingMerge.d.ts +2 -7
  28. package/dist/core/groundingMerge.js +35 -54
  29. package/dist/core/migrate.d.ts +10 -1
  30. package/dist/core/migrate.js +17 -6
  31. package/dist/core/paths.d.ts +17 -24
  32. package/dist/core/paths.js +37 -43
  33. package/dist/core/projectDna.js +11 -1
  34. package/dist/core/refrepair.d.ts +2 -1
  35. package/dist/core/refrepair.js +4 -4
  36. package/dist/core/relativeImports.js +6 -2
  37. package/dist/core/repairqueue.d.ts +17 -34
  38. package/dist/core/repairqueue.js +48 -98
  39. package/dist/core/taskSelection.js +48 -6
  40. package/dist/core/testSelection.d.ts +122 -0
  41. package/dist/core/testSelection.js +588 -0
  42. package/dist/core/types.d.ts +3 -3
  43. package/dist/core/types.js +1 -1
  44. package/dist/extractors/diff.d.ts +4 -4
  45. package/dist/extractors/diff.js +25 -29
  46. package/dist/extractors/git.d.ts +8 -23
  47. package/dist/extractors/git.js +77 -95
  48. package/dist/extractors/helm.d.ts +10 -0
  49. package/dist/extractors/helm.js +43 -59
  50. package/dist/extractors/indexer.d.ts +6 -0
  51. package/dist/extractors/indexer.js +168 -184
  52. package/dist/extractors/k8sManifest.d.ts +23 -80
  53. package/dist/extractors/k8sManifest.js +319 -690
  54. package/dist/extractors/languages.d.ts +37 -37
  55. package/dist/extractors/languages.js +83 -54
  56. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  57. package/dist/extractors/nativeTreeSitter.js +17 -14
  58. package/dist/extractors/parse.d.ts +27 -13
  59. package/dist/extractors/parse.js +120 -56
  60. package/dist/extractors/workspaces.js +3 -1
  61. package/dist/integrations/claudemd.js +1 -0
  62. package/dist/integrations/gitignore.js +3 -5
  63. package/dist/integrations/health.d.ts +6 -0
  64. package/dist/integrations/health.js +33 -12
  65. package/dist/integrations/hooks.d.ts +13 -16
  66. package/dist/integrations/hooks.js +31 -30
  67. package/dist/integrations/mergeDriver.js +18 -17
  68. package/dist/integrations/optout.d.ts +3 -0
  69. package/dist/integrations/optout.js +17 -0
  70. package/dist/integrations/providers.d.ts +6 -2
  71. package/dist/integrations/providers.js +19 -2
  72. package/dist/integrations/team.js +4 -2
  73. package/dist/integrations/uninstall.d.ts +10 -4
  74. package/dist/integrations/uninstall.js +22 -18
  75. package/dist/mcp/server.d.ts +4 -5
  76. package/dist/mcp/server.js +107 -81
  77. package/dist/serve/app.js +9 -1
  78. package/dist/serve/config.js +5 -0
  79. package/dist/serve/writelock.d.ts +3 -1
  80. package/dist/serve/writelock.js +17 -3
  81. package/dist/store/hunchStore.d.ts +2 -1
  82. package/dist/store/hunchStore.js +20 -23
  83. package/dist/store/jsonStore.d.ts +13 -3
  84. package/dist/store/jsonStore.js +37 -13
  85. package/dist/store/stateBinding.js +17 -4
  86. package/dist/synthesis/provider.d.ts +19 -21
  87. package/dist/synthesis/provider.js +93 -154
  88. package/dist/synthesis/synthesize.d.ts +8 -8
  89. package/dist/synthesis/synthesize.js +33 -50
  90. package/package.json +3 -3
  91. package/server.json +2 -2
@@ -11,6 +11,7 @@ export interface ResolvedInvocation {
11
11
  * or npx cache), never from a source checkout. */
12
12
  installed: boolean;
13
13
  }
14
+ /** Faint (dim) ANSI styling. */
14
15
  export declare function dim(s: string): string;
15
16
  /** Portable invocation written into committed MCP/provider configuration.
16
17
  * Keep it independently testable so the distribution pin cannot silently
@@ -20,20 +21,14 @@ export declare function publishedMcpInvocation(): Invocation;
20
21
  * PowerShell can execute the first token; paths and other unsafe tokens use
21
22
  * JSON string quoting, which is accepted by PowerShell, cmd, and POSIX sh. */
22
23
  export declare function shellInvocation(inv: Invocation): string;
23
- /** The doctor command's synthesis-status line(s) for a resolved provider.
24
- * Exported for testing — the previous version (a bare provider-name switch,
25
- * before the resolveSynthesisProvider preference system existed) had zero
26
- * test coverage, which is how issue #8 (openai-compat misreported as "no
27
- * assistant CLI found") shipped unnoticed through three review passes. That
28
- * bug resurfaces here for the same reason: resolution.statuses carries a
29
- * `subscription` field for the CLI providers but openai-compat's is null (it
30
- * isn't a subscription), so it must be special-cased explicitly rather than
31
- * falling through to the "no assistant CLI" branch. */
24
+ /** Render doctor's synthesis status lines. Exported for testing: an earlier,
25
+ * untested version reported openai-compat as "no assistant CLI found". Statuses
26
+ * carry a `subscription` for CLI providers but openai-compat's is null, so it is
27
+ * special-cased explicitly. */
32
28
  export declare function synthesisStatusLines(resolution: ProviderResolution, env: NodeJS.ProcessEnv): string[];
33
- /** Gate + fetch the Ollama context-window advisory (issue #11): only relevant
34
- * for the openai-compat provider, so every other provider is a no-op. Kept
35
- * separate from synthesisStatusLines (sync, already fully covered) because
36
- * this one makes a best-effort network call. */
29
+ /** Gate + fetch the Ollama context-window advisory. Only openai-compat is
30
+ * relevant; kept apart from the sync status lines because it makes a
31
+ * best-effort network call. */
37
32
  export declare function maybeWarnOllamaContext(providerName: string, env: NodeJS.ProcessEnv): Promise<string | null>;
38
33
  /** Classify the entry path without faking import.meta.url in tests. */
39
34
  export declare function classifyEntry(entry: string): {
@@ -41,3 +36,17 @@ export declare function classifyEntry(entry: string): {
41
36
  installed: boolean;
42
37
  };
43
38
  export declare function resolveInvocation(): ResolvedInvocation;
39
+ /** The command prefix for the git merge drivers. git runs a driver with the
40
+ * repository being merged as its cwd, so the dev-mode `npx tsx <entry>` would
41
+ * resolve tsx from THAT repo (or fetch it from the registry), not from Hunch's
42
+ * own checkout (#492). In dev mode, launch the absolute node binary against
43
+ * this checkout's tsx cli.mjs, the same shape selfBuildInvocation gives the
44
+ * git hooks (src/integrations/hooks.ts); every other invocation (published,
45
+ * dist, or a caller-supplied one) passes through unchanged. */
46
+ export declare function mergeDriverShell(invShell: string): string;
47
+ /** Filesystem paths, never a file URL: git expands `%`-escapes in the driver
48
+ * command, so a percent-encoded URL (any non-ASCII checkout path) would be
49
+ * corrupted. POSIX single quotes keep `$`, backticks and `"` literal under the
50
+ * /bin/sh git runs drivers with on every platform, and a literal `%` in a path
51
+ * is doubled so git expands it back to one. */
52
+ export declare function devMergeDriverShell(execPath: string, tsxCli: string, entry: string): string;
@@ -1,12 +1,15 @@
1
- /** Side-effect-free shared CLI logic — safe for any module (including tests)
2
- * to import, unlike src/cli/index.ts, which runs the whole program at
3
- * import time. Holds: how to re-invoke this CLI from a git hook / .mcp.json
4
- * (working both when running the built dist and in dev via tsx), plus small
5
- * formatting helpers (dim(), doctor's synthesisStatusLines()) that need the
6
- * same import-safety to be unit-testable. */
1
+ /**
2
+ * Shared CLI logic that is free of import-time side effects, so any module
3
+ * (tests included) can import it safely, unlike src/cli/index.ts which runs the
4
+ * program as soon as it loads. It holds how to re-invoke this CLI from a git hook
5
+ * or `.mcp.json` (from a built dist and in dev via tsx), plus small formatting
6
+ * helpers (`dim()`, doctor's `synthesisStatusLines()`) that need the same import
7
+ * safety to be unit-testable.
8
+ */
7
9
  import { fileURLToPath } from "node:url";
8
10
  import { probeOllamaNumCtx } from "../synthesis/provider.js";
9
11
  import { HUNCH_NPX_PACKAGE_SPEC } from "../core/version.js";
12
+ /** Faint (dim) ANSI styling. */
10
13
  export function dim(s) {
11
14
  return `\x1b[2m${s}\x1b[0m`;
12
15
  }
@@ -23,15 +26,10 @@ export function shellInvocation(inv) {
23
26
  const token = (part) => /^[A-Za-z0-9_@:=+.,/-]+$/.test(part) ? part : JSON.stringify(part);
24
27
  return [inv.command, ...inv.args].map(token).join(" ");
25
28
  }
26
- /** The doctor command's synthesis-status line(s) for a resolved provider.
27
- * Exported for testing — the previous version (a bare provider-name switch,
28
- * before the resolveSynthesisProvider preference system existed) had zero
29
- * test coverage, which is how issue #8 (openai-compat misreported as "no
30
- * assistant CLI found") shipped unnoticed through three review passes. That
31
- * bug resurfaces here for the same reason: resolution.statuses carries a
32
- * `subscription` field for the CLI providers but openai-compat's is null (it
33
- * isn't a subscription), so it must be special-cased explicitly rather than
34
- * falling through to the "no assistant CLI" branch. */
29
+ /** Render doctor's synthesis status lines. Exported for testing: an earlier,
30
+ * untested version reported openai-compat as "no assistant CLI found". Statuses
31
+ * carry a `subscription` for CLI providers but openai-compat's is null, so it is
32
+ * special-cased explicitly. */
35
33
  export function synthesisStatusLines(resolution, env) {
36
34
  const provider = resolution.provider;
37
35
  if (resolution.source === "unavailable-initiator") {
@@ -43,39 +41,38 @@ export function synthesisStatusLines(resolution, env) {
43
41
  if (resolution.source === "initiator") {
44
42
  return [` ↳ agent calls remain with ${resolution.initiator}, using that CLI's configured authentication.`];
45
43
  }
46
- const selected = resolution.statuses.find((s) => s.name === provider.name);
47
- if (selected?.subscription) {
48
- return [` ↳ LLM synthesis uses your ${selected.subscription}; provider API credentials are not used.`];
44
+ const status = resolution.statuses.find((s) => s.name === provider.name);
45
+ if (status?.subscription) {
46
+ return [` ↳ LLM synthesis uses your ${status.subscription}; provider API credentials are not used.`];
49
47
  }
50
48
  if (provider.name === "openai-compat") {
51
- const base = env.HUNCH_SYNTH_BASE_URL ?? "(unset)";
52
- const model = env.HUNCH_SYNTH_MODEL ?? "(unset)";
49
+ const base = env.HUNCH_SYNTH_BASE_URL || "(unset)";
50
+ const model = env.HUNCH_SYNTH_MODEL || "(unset)";
53
51
  const keyNote = env.HUNCH_SYNTH_API_KEY ? " (HUNCH_SYNTH_API_KEY set)" : " (no API key)";
54
52
  return [` ↳ LLM synthesis via local/self-hosted endpoint ${base} (model: ${model})${keyNote}`];
55
53
  }
56
54
  if (resolution.source === "ambiguous") {
57
- const names = resolution.statuses.filter((s) => s.name !== "deterministic" && s.available).map((s) => s.name);
55
+ const names = resolution.statuses.filter((s) => s.available && s.name !== "deterministic").map((s) => s.name);
58
56
  return [
59
57
  dim(` ↳ ${names.join(", ")} are available; Hunch will not guess which provider to use.`),
60
- dim(` choose one locally: ${names.map((name) => `hunch provider ${name}`).join(" or ")}`),
58
+ dim(` choose one locally: ${names.map((n) => `hunch provider ${n}`).join(" or ")}`),
61
59
  ];
62
60
  }
63
61
  if (resolution.source === "unavailable-preference") {
64
62
  return [dim(` ↳ ${resolution.preference} was selected but is unavailable; using the offline heuristic.`)];
65
63
  }
66
64
  return [
67
- dim(` ↳ no assistant CLI found — synthesis uses the offline heuristic (advisory, low-confidence).`),
68
- dim(` install or log into Claude Code, Codex, or Cursor; then select one with \`hunch provider <name>\`.`),
65
+ dim(" ↳ no assistant CLI found — synthesis uses the offline heuristic (advisory, low-confidence)."),
66
+ dim(" install or log into Claude Code, Codex, or Cursor; then select one with `hunch provider <name>`."),
69
67
  ];
70
68
  }
71
- /** Gate + fetch the Ollama context-window advisory (issue #11): only relevant
72
- * for the openai-compat provider, so every other provider is a no-op. Kept
73
- * separate from synthesisStatusLines (sync, already fully covered) because
74
- * this one makes a best-effort network call. */
69
+ /** Gate + fetch the Ollama context-window advisory. Only openai-compat is
70
+ * relevant; kept apart from the sync status lines because it makes a
71
+ * best-effort network call. */
75
72
  export async function maybeWarnOllamaContext(providerName, env) {
76
73
  if (providerName !== "openai-compat")
77
74
  return null;
78
- return probeOllamaNumCtx(env.HUNCH_SYNTH_BASE_URL ?? "", env.HUNCH_SYNTH_MODEL ?? "");
75
+ return probeOllamaNumCtx(env.HUNCH_SYNTH_BASE_URL || "", env.HUNCH_SYNTH_MODEL || "");
79
76
  }
80
77
  /** Classify the entry path without faking import.meta.url in tests. */
81
78
  export function classifyEntry(entry) {
@@ -125,4 +122,27 @@ export function resolveInvocation() {
125
122
  installed,
126
123
  };
127
124
  }
125
+ /** The command prefix for the git merge drivers. git runs a driver with the
126
+ * repository being merged as its cwd, so the dev-mode `npx tsx <entry>` would
127
+ * resolve tsx from THAT repo (or fetch it from the registry), not from Hunch's
128
+ * own checkout (#492). In dev mode, launch the absolute node binary against
129
+ * this checkout's tsx cli.mjs, the same shape selfBuildInvocation gives the
130
+ * git hooks (src/integrations/hooks.ts); every other invocation (published,
131
+ * dist, or a caller-supplied one) passes through unchanged. */
132
+ export function mergeDriverShell(invShell) {
133
+ const inv = resolveInvocation();
134
+ if (inv.installed || !inv.shell.startsWith("npx tsx ") || invShell !== inv.shell)
135
+ return invShell;
136
+ const entry = fileURLToPath(import.meta.url).replace(/invocation\.ts$/, "index.ts");
137
+ return devMergeDriverShell(process.execPath, fileURLToPath(import.meta.resolve("tsx/cli")), entry);
138
+ }
139
+ /** Filesystem paths, never a file URL: git expands `%`-escapes in the driver
140
+ * command, so a percent-encoded URL (any non-ASCII checkout path) would be
141
+ * corrupted. POSIX single quotes keep `$`, backticks and `"` literal under the
142
+ * /bin/sh git runs drivers with on every platform, and a literal `%` in a path
143
+ * is doubled so git expands it back to one. */
144
+ export function devMergeDriverShell(execPath, tsxCli, entry) {
145
+ const quote = (s) => `'${s.replace(/'/g, "'\\''")}'`.replace(/%/g, "%%");
146
+ return [execPath, tsxCli, entry].map(quote).join(" ");
147
+ }
128
148
  //# 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
@@ -27,10 +27,10 @@ function view(file, source) {
27
27
  }
28
28
  function viewOfParsed(parsed) {
29
29
  const symbols = new Map(parsed.symbols.map((s) => [symbolKey(s), s]));
30
- const byIndex = new Map(parsed.symbols.map((s, i) => [i, s]));
30
+ const symbolAt = new Map(parsed.symbols.map((s, i) => [i, s]));
31
31
  const calls = new Map();
32
32
  for (const [index, callees] of attributeCalls(parsed)) {
33
- const caller = byIndex.get(index);
33
+ const caller = symbolAt.get(index);
34
34
  if (!caller)
35
35
  continue;
36
36
  for (const [callee, member] of callees) {
@@ -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";
@@ -93,21 +93,23 @@ function parsedSymbolFor(graphSymbol, parsed) {
93
93
  const base = symbolId(graphSymbol.file, graphSymbol.name, graphSymbol.kind);
94
94
  return matches.find((_symbol, index) => (index === 0 ? base : `${base}_${index}`) === graphSymbol.id) ?? null;
95
95
  }
96
- /** `start`/`end` are JS string (UTF-16 code unit) indices. Despite their
97
- * names, parse.ts's startByte/endByte/atByte carry the same units — native
98
- * tree-sitter indexes the JS string it was handed, not its UTF-8 encoding —
99
- * so every scan and splice against them must be string-based, never Buffer-
100
- * based. */
101
- function spliceChars(source, replacements) {
96
+ /** Apply `{ start, end, text }` replacements to `source`. Offsets are JavaScript
97
+ * string (UTF-16 code unit) indices: the parser's `startByte`/`endByte`/`atByte`
98
+ * fields, despite their names, carry the same units (tree-sitter indexes the JS
99
+ * string it was given, not its UTF-8 bytes), so every scan and splice against
100
+ * them must be string-based, never Buffer/byte-based. */
101
+ function applyReplacements(source, replacements) {
102
102
  let result = source;
103
103
  for (const replacement of [...replacements].sort((a, b) => b.start - a.start)) {
104
104
  result = result.slice(0, replacement.start) + replacement.text + result.slice(replacement.end);
105
105
  }
106
106
  return result;
107
107
  }
108
- /** Where a new top-level statement (an import) can be inserted without splitting a shebang line. */
109
- function insertionPoint(source) {
110
- return source.startsWith("#!") ? Math.max(0, source.indexOf("\n") + 1) : 0;
108
+ /** Offset where a new top-level import may go: 0, or just past a leading shebang line. */
109
+ function importInsertionOffset(source) {
110
+ if (!source.startsWith("#!"))
111
+ return 0;
112
+ return Math.max(0, source.indexOf("\n") + 1);
111
113
  }
112
114
  function mutateSource(policy, base, sourceFile, source) {
113
115
  const assertion = policy.assertion;
@@ -150,10 +152,14 @@ function mutateSource(policy, base, sourceFile, source) {
150
152
  const specifier = relativeSpecifier(sourceFile, targetFile);
151
153
  if (parsed.imports.some((candidate) => candidate === specifier))
152
154
  return { error: "mutation-component-import-already-present" };
153
- const insertion = insertionPoint(source);
155
+ const insertion = importInsertionOffset(source);
154
156
  return {
155
157
  file: sourceFile,
156
- source: spliceChars(source, [{ start: insertion, end: insertion, text: `import ${JSON.stringify(specifier)}; // hunch deterministic component mutation\n` }]),
158
+ source: applyReplacements(source, [{
159
+ start: insertion,
160
+ end: insertion,
161
+ text: `import ${JSON.stringify(specifier)}; // hunch deterministic component mutation\n`,
162
+ }]),
157
163
  };
158
164
  }
159
165
  const subject = symbolForSelector(base, assertion.subject);
@@ -166,7 +172,7 @@ function mutateSource(policy, base, sourceFile, source) {
166
172
  if (!definition)
167
173
  return { error: "mutation-subject-definition-unresolved" };
168
174
  if (assertion.kind === "exists") {
169
- return { file: subject.file, source: spliceChars(source, [{ start: definition.startByte, end: definition.endByte, text: "" }]) };
175
+ return { file: subject.file, source: applyReplacements(source, [{ start: definition.startByte, end: definition.endByte, text: "" }]) };
170
176
  }
171
177
  if (assertion.kind === "not-reaches"
172
178
  && assertion.relation.edges.length === 1
@@ -178,10 +184,14 @@ function mutateSource(policy, base, sourceFile, source) {
178
184
  if (parsed.imports.some((specifier) => externalPackage(specifier) === dependency)) {
179
185
  return { error: "mutation-forbidden-import-already-present" };
180
186
  }
181
- const insertion = insertionPoint(source);
187
+ const insertion = importInsertionOffset(source);
182
188
  return {
183
189
  file: subject.file,
184
- source: spliceChars(source, [{ start: insertion, end: insertion, text: `import ${JSON.stringify(dependency)}; // hunch deterministic source mutation\n` }]),
190
+ source: applyReplacements(source, [{
191
+ start: insertion,
192
+ end: insertion,
193
+ text: `import ${JSON.stringify(dependency)}; // hunch deterministic source mutation\n`,
194
+ }]),
185
195
  };
186
196
  }
187
197
  const object = symbolForSelector(base, assertion.object);
@@ -198,11 +208,11 @@ function mutateSource(policy, base, sourceFile, source) {
198
208
  .map((call) => ({ start: call.atByte, end: call.endByte, text: "hunchMutationRemovedCall" }));
199
209
  if (!replacements.length)
200
210
  return { error: "mutation-required-call-unresolved" };
201
- return { file: subject.file, source: spliceChars(source, replacements) };
211
+ return { file: subject.file, source: applyReplacements(source, replacements) };
202
212
  }
203
213
  if (!assertion.relation.edges.includes("calls"))
204
214
  return { error: "mutation-call-edge-not-supported" };
205
- // String search, matching definition.startByte/endByte's actual units -- see spliceChars' doc comment.
215
+ // String search: offsets are string indices (see applyReplacements), not bytes.
206
216
  const open = source.indexOf("{", definition.startByte);
207
217
  if (open < 0 || open >= definition.endByte)
208
218
  return { error: "mutation-subject-body-unsupported" };
@@ -210,7 +220,7 @@ function mutateSource(policy, base, sourceFile, source) {
210
220
  if (object.file !== subject.file) {
211
221
  const specifier = relativeSpecifier(subject.file, object.file);
212
222
  if (!parsed.imports.includes(specifier)) {
213
- const insertion = insertionPoint(source);
223
+ const insertion = importInsertionOffset(source);
214
224
  replacements.push({
215
225
  start: insertion,
216
226
  end: insertion,
@@ -220,7 +230,7 @@ function mutateSource(policy, base, sourceFile, source) {
220
230
  }
221
231
  return {
222
232
  file: subject.file,
223
- source: spliceChars(source, replacements),
233
+ source: applyReplacements(source, replacements),
224
234
  };
225
235
  }
226
236
  function removeWorktree(root, hooks, env, checkout) {