@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.
- package/dist/cli/index.js +139 -32
- package/dist/cli/invocation.d.ts +14 -0
- package/dist/cli/invocation.js +23 -0
- package/dist/cli/verify.d.ts +28 -0
- package/dist/cli/verify.js +265 -0
- package/dist/constitution/experiment.d.ts +1 -1
- 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/config.d.ts +6 -0
- package/dist/core/config.js +1 -0
- package/dist/core/delivery.d.ts +6 -1
- package/dist/core/delivery.js +71 -5
- package/dist/core/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- package/dist/core/projectDna.js +11 -1
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/taskSelection.js +48 -6
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- package/dist/extractors/git.js +32 -31
- package/dist/extractors/indexer.js +42 -6
- package/dist/extractors/languages.d.ts +9 -0
- package/dist/extractors/languages.js +28 -0
- package/dist/extractors/parse.d.ts +20 -0
- package/dist/extractors/parse.js +81 -5
- package/dist/extractors/workspaces.js +3 -1
- package/dist/integrations/health.d.ts +6 -0
- package/dist/integrations/health.js +33 -12
- package/dist/integrations/hooks.d.ts +4 -2
- package/dist/integrations/hooks.js +12 -2
- package/dist/integrations/mergeDriver.js +5 -3
- 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.js +47 -8
- 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 +8 -5
- 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.js +26 -78
- package/dist/synthesis/synthesize.js +1 -1
- package/package.json +2 -2
- 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,
|
|
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
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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${
|
|
6100
|
-
console.log(`✓ Migrated v${from} → v${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7737
|
+
say(`✓ healed Claude Code project case-split: mirrored [${g.servers.join(", ")}] across ${g.casings.join(" · ")}`);
|
|
7631
7738
|
}
|
|
7632
|
-
|
|
7739
|
+
say(dim(` ↳ backup: ${res.backup}`));
|
|
7633
7740
|
}
|
|
7634
7741
|
function fail(msg) {
|
|
7635
7742
|
console.error(`error: ${msg}`);
|
package/dist/cli/invocation.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/invocation.js
CHANGED
|
@@ -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";
|