@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.
- package/dist/cli/index.js +290 -353
- package/dist/cli/invocation.d.ts +22 -13
- package/dist/cli/invocation.js +49 -29
- package/dist/cli/verify.d.ts +28 -0
- package/dist/cli/verify.js +265 -0
- package/dist/constitution/delta.js +2 -2
- package/dist/constitution/experiment.d.ts +1 -1
- package/dist/constitution/sourceMutation.js +28 -18
- package/dist/core/agenthook.js +26 -6
- package/dist/core/boundedSpawn.d.ts +57 -0
- package/dist/core/boundedSpawn.js +321 -0
- package/dist/core/commitrepair.d.ts +43 -138
- package/dist/core/commitrepair.js +76 -167
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +1 -0
- package/dist/core/correction.js +9 -9
- package/dist/core/delivery.d.ts +6 -1
- package/dist/core/delivery.js +71 -5
- package/dist/core/docanchors.js +9 -9
- package/dist/core/drift.js +22 -27
- package/dist/core/escalations.d.ts +16 -58
- package/dist/core/escalations.js +39 -121
- package/dist/core/glob.d.ts +7 -7
- package/dist/core/glob.js +12 -11
- package/dist/core/groundingLag.d.ts +2 -9
- package/dist/core/groundingLag.js +5 -11
- package/dist/core/groundingMerge.d.ts +2 -7
- package/dist/core/groundingMerge.js +35 -54
- package/dist/core/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- package/dist/core/paths.d.ts +17 -24
- package/dist/core/paths.js +37 -43
- package/dist/core/projectDna.js +11 -1
- package/dist/core/refrepair.d.ts +2 -1
- package/dist/core/refrepair.js +4 -4
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/repairqueue.d.ts +17 -34
- package/dist/core/repairqueue.js +48 -98
- package/dist/core/taskSelection.js +48 -6
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- package/dist/core/types.d.ts +3 -3
- package/dist/core/types.js +1 -1
- package/dist/extractors/diff.d.ts +4 -4
- package/dist/extractors/diff.js +25 -29
- package/dist/extractors/git.d.ts +8 -23
- package/dist/extractors/git.js +77 -95
- package/dist/extractors/helm.d.ts +10 -0
- package/dist/extractors/helm.js +43 -59
- package/dist/extractors/indexer.d.ts +6 -0
- package/dist/extractors/indexer.js +168 -184
- package/dist/extractors/k8sManifest.d.ts +23 -80
- package/dist/extractors/k8sManifest.js +319 -690
- package/dist/extractors/languages.d.ts +37 -37
- package/dist/extractors/languages.js +83 -54
- package/dist/extractors/nativeTreeSitter.d.ts +4 -4
- package/dist/extractors/nativeTreeSitter.js +17 -14
- package/dist/extractors/parse.d.ts +27 -13
- package/dist/extractors/parse.js +120 -56
- package/dist/extractors/workspaces.js +3 -1
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.js +3 -5
- package/dist/integrations/health.d.ts +6 -0
- package/dist/integrations/health.js +33 -12
- package/dist/integrations/hooks.d.ts +13 -16
- package/dist/integrations/hooks.js +31 -30
- package/dist/integrations/mergeDriver.js +18 -17
- package/dist/integrations/optout.d.ts +3 -0
- package/dist/integrations/optout.js +17 -0
- package/dist/integrations/providers.d.ts +6 -2
- package/dist/integrations/providers.js +19 -2
- package/dist/integrations/team.js +4 -2
- package/dist/integrations/uninstall.d.ts +10 -4
- package/dist/integrations/uninstall.js +22 -18
- package/dist/mcp/server.d.ts +4 -5
- package/dist/mcp/server.js +107 -81
- package/dist/serve/app.js +9 -1
- package/dist/serve/config.js +5 -0
- package/dist/serve/writelock.d.ts +3 -1
- package/dist/serve/writelock.js +17 -3
- package/dist/store/hunchStore.d.ts +2 -1
- package/dist/store/hunchStore.js +20 -23
- package/dist/store/jsonStore.d.ts +13 -3
- package/dist/store/jsonStore.js +37 -13
- package/dist/store/stateBinding.js +17 -4
- package/dist/synthesis/provider.d.ts +19 -21
- package/dist/synthesis/provider.js +93 -154
- package/dist/synthesis/synthesize.d.ts +8 -8
- package/dist/synthesis/synthesize.js +33 -50
- package/package.json +3 -3
- package/server.json +2 -2
package/dist/cli/invocation.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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;
|
package/dist/cli/invocation.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
47
|
-
if (
|
|
48
|
-
return [` ↳ LLM synthesis uses your ${
|
|
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
|
|
52
|
-
const model = env.HUNCH_SYNTH_MODEL
|
|
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"
|
|
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((
|
|
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(
|
|
68
|
-
dim(
|
|
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
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* so every scan and splice against
|
|
100
|
-
* based. */
|
|
101
|
-
function
|
|
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
|
-
/**
|
|
109
|
-
function
|
|
110
|
-
|
|
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 =
|
|
155
|
+
const insertion = importInsertionOffset(source);
|
|
154
156
|
return {
|
|
155
157
|
file: sourceFile,
|
|
156
|
-
source:
|
|
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:
|
|
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 =
|
|
187
|
+
const insertion = importInsertionOffset(source);
|
|
182
188
|
return {
|
|
183
189
|
file: subject.file,
|
|
184
|
-
source:
|
|
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:
|
|
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
|
|
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 =
|
|
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:
|
|
233
|
+
source: applyReplacements(source, replacements),
|
|
224
234
|
};
|
|
225
235
|
}
|
|
226
236
|
function removeWorktree(root, hooks, env, checkout) {
|