@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
@@ -0,0 +1,588 @@
1
+ /** Change-scoped test selection for `hunch verify` (dec_70d82c408e). Pure
2
+ * selection rules over injected inputs, plus the git/filesystem readers that
3
+ * feed them. FAIL CLOSED: a change nothing can attribute runs the full suite,
4
+ * and a test that starts a child process is assumed to reach the whole program
5
+ * unless a trustworthy impact-map entry narrows it. */
6
+ import { execFileSync } from "node:child_process";
7
+ import { createHash } from "node:crypto";
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { isAbsolute, join, relative } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { pathMatchesGlob } from "./glob.js";
12
+ import { relativeImportCandidates } from "./relativeImports.js";
13
+ export const DEFAULT_FULL_TRIGGERS = [
14
+ "package.json",
15
+ "package-lock.json",
16
+ "tsconfig*.json",
17
+ "tooling/run-tests.mjs",
18
+ "test/fixtures.ts",
19
+ "test/helpers*",
20
+ "test/cli-invocation.ts",
21
+ ".github/workflows/**",
22
+ ];
23
+ /** Repo-root markdown (README.md, CHANGELOG.md, ...) is the only non-code change
24
+ * that can skip the full suite: it selects tests that mention it, else nothing. */
25
+ export function isDocsOnly(file) {
26
+ return !file.includes("/") && file.endsWith(".md");
27
+ }
28
+ /** Source the import graph can follow. Any other changed file may be read at
29
+ * runtime in ways no static scan sees (readdirSync walks, globs), so it runs FULL. */
30
+ const CODE_FILE_RE = /\.(?:tsx?|jsx?|mjs|cjs)$/;
31
+ /** Selected tests above this share of the suite run the full suite instead. */
32
+ export const MAX_SELECTED_SHARE = 0.6;
33
+ const norm = (p) => p.replace(/\\/g, "/").replace(/^\.\//, "");
34
+ const sorted = (xs) => [...new Set(xs)].sort();
35
+ const basename = (p) => p.slice(p.lastIndexOf("/") + 1);
36
+ /** A basename mention bounded by non-path characters ("d.mjs" ≠ "child.mjs"). */
37
+ const mentionRe = (name) => new RegExp(`(?<![\\w.-])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w-])`);
38
+ /** A test file by name, existing or deleted: `test/.../*.test.<ext>`. Other
39
+ * files under test/ are helpers and keep the code/trigger rules. */
40
+ const TEST_FILE_RE = /^test\/(?:.+\/)?[^/]+\.test\.[cm]?[jt]sx?$/;
41
+ /** A test that enumerates test files (a readdir/glob over a test directory, a
42
+ * generic `.test.ts` suffix) or reads the repo index: adding, deleting or
43
+ * editing any test file can change what it sees. Over-matching only widens. */
44
+ const META_TEST_RE = /\b(?:readdir|readdirSync|opendir|opendirSync|glob|globSync)\s*\([^)]*test|["'`*]\.test\.[cm]?[jt]sx?\b|\.hunch[\\/"', ]+index\.json/;
45
+ export const isMetaTest = (text) => META_TEST_RE.test(text);
46
+ /** A map entry may narrow a spawning test only when it is complete and was
47
+ * recorded against the same entry closure as now. */
48
+ export function entryTrusted(entry, closureHash) {
49
+ return !!entry && entry.complete === true && !!entry.closureHash && entry.closureHash === closureHash;
50
+ }
51
+ export function selectTests(input) {
52
+ const changed = sorted(input.changedFiles.map(norm));
53
+ const allTests = sorted(input.allTests.map(norm));
54
+ const testSet = new Set(allTests);
55
+ const triggers = input.fullTriggers ?? DEFAULT_FULL_TRIGGERS;
56
+ const whole = new Set(input.wholeProgram ?? []);
57
+ const entries = input.impactMap?.map ?? {};
58
+ const reasons = new Map();
59
+ const unattributed = [];
60
+ const nonCode = [];
61
+ const mapUsed = new Set();
62
+ let triggerReason;
63
+ const pick = (test, why) => {
64
+ if (!testSet.has(test))
65
+ return;
66
+ let set = reasons.get(test);
67
+ if (!set)
68
+ reasons.set(test, set = new Set());
69
+ set.add(why);
70
+ };
71
+ for (const file of changed) {
72
+ const trigger = triggers.find((g) => pathMatchesGlob(file, g));
73
+ if (trigger) {
74
+ triggerReason ??= `${file} matches full-suite trigger ${trigger}`;
75
+ continue;
76
+ }
77
+ let attributed = false;
78
+ const hit = (test, why) => { pick(test, why); attributed = true; };
79
+ /** Tests a change to code file `target` reaches (graph, map, whole program). */
80
+ const viaCode = (target, note) => {
81
+ for (const test of allTests) {
82
+ const entry = entries[test];
83
+ // The map only ever ADDS: a stale or partial entry never hides a test
84
+ // the static graph (or the whole-program rule) still attributes.
85
+ if (entry?.files.includes(target)) {
86
+ hit(test, `covers ${target} (impact map)${note}`);
87
+ mapUsed.add(test);
88
+ }
89
+ if (input.importGraph(test).has(target))
90
+ hit(test, `imports ${target}${note}`);
91
+ if (whole.has(target) && input.spawning?.(test)) {
92
+ if (entryTrusted(entry, input.closureHash))
93
+ mapUsed.add(test);
94
+ else
95
+ hit(test, `spawns a process without a trusted impact-map entry → whole program (${target})${note}`);
96
+ }
97
+ }
98
+ };
99
+ for (const [test, globs] of Object.entries(input.gateMaps ?? {})) {
100
+ if (globs.some((g) => pathMatchesGlob(file, g)))
101
+ hit(norm(test), `gate map covers ${file}`);
102
+ }
103
+ if (testSet.has(file) || TEST_FILE_RE.test(file)) {
104
+ // A changed, added or deleted test file: itself, tests that name it, tests
105
+ // that enumerate test files, and tests reaching src/tooling that names it.
106
+ // A deleted one nothing reads stays unattributed → FULL.
107
+ if (testSet.has(file))
108
+ hit(file, "changed test file");
109
+ const name = mentionRe(basename(file));
110
+ for (const test of allTests) {
111
+ if (test === file)
112
+ continue;
113
+ const text = input.testText?.(test) ?? "";
114
+ if (text.includes(file) || name.test(text))
115
+ hit(test, `mentions ${file}`);
116
+ if (isMetaTest(text))
117
+ hit(test, `enumerates test files (${file})`);
118
+ }
119
+ for (const namer of input.testNamers?.(file) ?? [])
120
+ viaCode(norm(namer), ` (names ${file})`);
121
+ if (!attributed)
122
+ unattributed.push(file);
123
+ continue;
124
+ }
125
+ if (isDocsOnly(file)) {
126
+ for (const test of allTests) {
127
+ const text = input.testText?.(test) ?? "";
128
+ if (text.includes(file) || mentionRe(basename(file)).test(text))
129
+ hit(test, `mentions ${file}`);
130
+ }
131
+ continue;
132
+ }
133
+ if (!CODE_FILE_RE.test(file)) {
134
+ nonCode.push(file);
135
+ continue;
136
+ }
137
+ viaCode(file, "");
138
+ if (!attributed)
139
+ unattributed.push(file);
140
+ }
141
+ const reasonRecord = {};
142
+ for (const test of sorted(reasons.keys()))
143
+ reasonRecord[test] = sorted(reasons.get(test));
144
+ const selected = Object.keys(reasonRecord);
145
+ const used = sorted(mapUsed);
146
+ const full = (fullReason) => ({ mode: "full", tests: allTests, reasons: reasonRecord, fullReason, unattributed, mapUsed: used });
147
+ if (triggerReason)
148
+ return full(triggerReason);
149
+ if (nonCode.length) {
150
+ unattributed.unshift(...nonCode);
151
+ const more = nonCode.length > 1 ? ` (+${nonCode.length - 1} more)` : "";
152
+ return full(`non-code change ${nonCode[0]}${more}: runtime file reads are not traceable`);
153
+ }
154
+ if (unattributed.length) {
155
+ const more = unattributed.length > 1 ? ` (+${unattributed.length - 1} more)` : "";
156
+ return full(`no test attributed to ${unattributed[0]}${more}`);
157
+ }
158
+ if (selected.length > allTests.length * MAX_SELECTED_SHARE) {
159
+ return full(`selection too broad (${selected.length} of ${allTests.length} > ${MAX_SELECTED_SHARE * 100}%)`);
160
+ }
161
+ if (!selected.length)
162
+ return { mode: "none", tests: [], reasons: {}, unattributed, mapUsed: used };
163
+ return { mode: "selected", tests: selected, reasons: reasonRecord, unattributed, mapUsed: used };
164
+ }
165
+ // ---- repo readers ---------------------------------------------------------
166
+ function git(root, args) {
167
+ return execFileSync("git", args, { cwd: root, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
168
+ }
169
+ /** Tracked + untracked (non-ignored) repo files, repo-relative posix, sorted. */
170
+ export function listRepoFiles(root) {
171
+ const out = git(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
172
+ return sorted(out.split("\0").filter(Boolean).map(norm).filter((f) => existsSync(join(root, f))));
173
+ }
174
+ export function discoverTests(files, glob = "test/**/*.test.ts") {
175
+ return sorted(files.filter((f) => pathMatchesGlob(f, glob)));
176
+ }
177
+ const CODE_FILE = /\.(?:[cm]?[jt]sx?)$/;
178
+ const IMPORT_RE = /(?:\bimport\s*(?:type\s+)?(?:[\w*{}\s,$]+?\s*from\s*)?|\bexport\s+(?:type\s+)?(?:\*(?:\s+as\s+[\w$]+)?|\{[^}]*\})\s*from\s*|\bimport\s*\(\s*|\brequire\s*\(\s*)["']([^"'\n]+)["']/g;
179
+ /** Static imports, re-exports, `import()`/`require()` with literal specifiers. */
180
+ export function importSpecifiers(source) {
181
+ const out = [];
182
+ for (const m of source.matchAll(IMPORT_RE))
183
+ out.push(m[1]);
184
+ return out;
185
+ }
186
+ /** Transitive relative-import closure per file, memoized for this call. An
187
+ * unresolvable relative specifier (e.g. a deleted file) records every
188
+ * candidate path, so the deletion stays attributable to its importers. */
189
+ export function buildImportGraph(root, files = listRepoFiles(root)) {
190
+ const available = new Set(files);
191
+ const direct = new Map();
192
+ const edges = (file) => {
193
+ let deps = direct.get(file);
194
+ if (deps)
195
+ return deps;
196
+ deps = [];
197
+ if (CODE_FILE.test(file) && available.has(file)) {
198
+ let text = "";
199
+ try {
200
+ text = readFileSync(join(root, file), "utf8");
201
+ }
202
+ catch { /* unreadable → no edges */ }
203
+ for (const spec of importSpecifiers(text)) {
204
+ const candidates = relativeImportCandidates(file, spec);
205
+ const hit = candidates.find((c) => available.has(c));
206
+ if (hit)
207
+ deps.push(hit);
208
+ else
209
+ deps.push(...candidates);
210
+ }
211
+ }
212
+ direct.set(file, deps);
213
+ return deps;
214
+ };
215
+ const closure = new Map();
216
+ return (file) => {
217
+ const start = norm(file);
218
+ const cached = closure.get(start);
219
+ if (cached)
220
+ return cached;
221
+ const seen = new Set();
222
+ const stack = [...edges(start)];
223
+ while (stack.length) {
224
+ const next = stack.pop();
225
+ if (seen.has(next) || next === start)
226
+ continue;
227
+ seen.add(next);
228
+ stack.push(...edges(next));
229
+ }
230
+ closure.set(start, seen);
231
+ return seen;
232
+ };
233
+ }
234
+ /** A test (or one of its non-src helpers) that can start a child process. Over-
235
+ * matching only widens selection, so any child_process use counts. */
236
+ const SPAWN_RE = /child_process|\bexeca\b|cli-invocation|hunchCliArgs|StdioClientTransport|(?<![\w$])(?:spawn|spawnSync|execFile|execFileSync|execSync|exec|fork)\s*\(/;
237
+ /** Entries whose import closure (edges included) keys map-entry trust. */
238
+ export const CLOSURE_ROOTS = ["src/cli/index.ts", "src/mcp/server.ts", "src/cli/dna.ts"];
239
+ const PATH_TOKEN_RE = /[\w.@-]+(?:\/[\w.@-]+)+/g;
240
+ const JOIN_CALL_RE = /\b(?:join|resolve)\s*\(/g;
241
+ /** String-literal arguments of each `join(`/`resolve(` call, found with a
242
+ * balanced-paren scan so nested calls (`join(process.cwd(), "src")`) parse. */
243
+ export function joinSegmentRuns(text) {
244
+ const runs = [];
245
+ for (const m of text.matchAll(JOIN_CALL_RE)) {
246
+ const segs = [];
247
+ let depth = 1;
248
+ const stop = Math.min(text.length, m.index + m[0].length + 4000);
249
+ for (let i = m.index + m[0].length; i < stop && depth > 0; i++) {
250
+ const ch = text[i];
251
+ if (ch === '"' || ch === "'" || ch === "`") {
252
+ const end = text.indexOf(ch, i + 1);
253
+ if (end < 0)
254
+ break;
255
+ if (depth === 1 && end > i + 1)
256
+ segs.push(text.slice(i + 1, end));
257
+ i = end;
258
+ }
259
+ else if (ch === "(")
260
+ depth++;
261
+ else if (ch === ")")
262
+ depth--;
263
+ }
264
+ runs.push(segs);
265
+ }
266
+ return runs;
267
+ }
268
+ /** Repo files a test's text names: path-like tokens anywhere (string literals,
269
+ * `--eval` import specifiers, template strings) and `join(…, "src", "x.ts")`
270
+ * segment runs, resolved against the REPO ROOT (and the test's own dir for
271
+ * `./`/`../` tokens), with `.js`→`.ts` mapping. */
272
+ export function literalRepoPaths(text, fromFile, available) {
273
+ const out = new Set();
274
+ const tryPath = (raw) => {
275
+ const p = norm(raw);
276
+ const bases = p.startsWith(".") ? relativeImportCandidates(fromFile, p) : [];
277
+ const rooted = p.replace(/^(?:\.\.?\/)+/, "");
278
+ const variants = (q) => [q, q.replace(/\.js$/, ".ts"), q.replace(/\.jsx$/, ".tsx"), `${q}.ts`];
279
+ // Bare src-relative names ("core/x.ts") read via `../src/${name}` resolve under src/ too.
280
+ for (const c of [...bases, ...variants(rooted), ...variants(`src/${rooted}`)]) {
281
+ if (available.has(c)) {
282
+ out.add(c);
283
+ return;
284
+ }
285
+ }
286
+ };
287
+ for (const m of text.matchAll(PATH_TOKEN_RE))
288
+ tryPath(m[0]);
289
+ for (const segs of joinSegmentRuns(text)) {
290
+ for (let i = 0; i < segs.length; i++)
291
+ tryPath(segs.slice(i).join("/"));
292
+ }
293
+ return sorted(out);
294
+ }
295
+ /** Arguments of each `join(`/`resolve(` call: a plain string literal's value, or
296
+ * `null` for anything computed (identifiers, calls, `${}` templates). */
297
+ export function joinCallArgs(text) {
298
+ const calls = [];
299
+ for (const m of text.matchAll(JOIN_CALL_RE)) {
300
+ const args = [];
301
+ let cur = "";
302
+ const flush = () => {
303
+ const t = cur.trim();
304
+ if (t)
305
+ args.push(/^(["'`])([^"'`$]*)\1$/.exec(t)?.[2] ?? null);
306
+ cur = "";
307
+ };
308
+ let depth = 1;
309
+ const stop = Math.min(text.length, m.index + m[0].length + 4000);
310
+ for (let i = m.index + m[0].length; i < stop; i++) {
311
+ const ch = text[i];
312
+ if (ch === '"' || ch === "'" || ch === "`") {
313
+ const end = text.indexOf(ch, i + 1);
314
+ if (end < 0)
315
+ break;
316
+ cur += text.slice(i, end + 1);
317
+ i = end;
318
+ continue;
319
+ }
320
+ if (ch === "(")
321
+ depth++;
322
+ else if (ch === ")" && --depth === 0) {
323
+ flush();
324
+ break;
325
+ }
326
+ else if (ch === "," && depth === 1) {
327
+ flush();
328
+ continue;
329
+ }
330
+ cur += ch;
331
+ }
332
+ calls.push(args);
333
+ }
334
+ return calls;
335
+ }
336
+ const TEMPLATE_PATH_RE = /(?<![\w.@-])((?:[\w.@-]+\/)*[\w.@-]+)\/\$\{/g;
337
+ const CONCAT_PATH_RE = /["'`]([\w.@/-]+)["'`]\s*\+/g;
338
+ /** `${ROOT}/${dir}`: a computed segment right after another computed one. */
339
+ const TEMPLATE_AFTER_EXPR_RE = /\}\/\$\{/;
340
+ /** `const SRC = join(ROOT, "src")` / `resolve(...)`: an alias of a repo dir. */
341
+ const DIR_ALIAS_RE = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:path\.)?(?:join|resolve)\s*\(/g;
342
+ const DOTS_ONLY = (p) => /^(?:\.{1,2}\/?)+$/.test(norm(p));
343
+ /** Top-level repo dirs a text reaches through a COMPUTED path: `` `../src/${p}` ``,
344
+ * `"src/" + p`, `join(root, "src", p)`, or a later use of an alias like
345
+ * `const SRC = join(ROOT, "src")`. A test doing that may read any file under
346
+ * the dir, so it depends on all of them. A computed segment right after the
347
+ * root (`` `../${dir}/x` ``, `"../" + dir`, `resolve(__dirname, "..", dir)`,
348
+ * `` `${ROOT}/${dir}` ``) may be ANY top-level dir (fail closed). */
349
+ export function templatedRepoDirs(text, topDirs) {
350
+ const out = new Set();
351
+ const all = () => { for (const dir of topDirs)
352
+ out.add(dir); };
353
+ const dirOf = (path) => norm(path).split("/").find((seg) => topDirs.has(seg));
354
+ const add = (path) => { const dir = dirOf(path); if (dir)
355
+ out.add(dir); };
356
+ for (const m of text.matchAll(TEMPLATE_PATH_RE))
357
+ DOTS_ONLY(m[1]) ? all() : add(m[1]);
358
+ if (TEMPLATE_AFTER_EXPR_RE.test(text))
359
+ all();
360
+ for (const m of text.matchAll(CONCAT_PATH_RE))
361
+ /^(?:\.{1,2}\/)+$/.test(m[1]) ? all() : add(m[1]);
362
+ for (const args of joinCallArgs(text)) {
363
+ args.forEach((arg, i) => {
364
+ if (arg === null)
365
+ return;
366
+ if (DOTS_ONLY(arg) && args[i + 1] === null)
367
+ all();
368
+ else if (args.slice(i + 1).includes(null))
369
+ add(arg);
370
+ });
371
+ }
372
+ for (const m of text.matchAll(DIR_ALIAS_RE)) {
373
+ const args = joinCallArgs(text.slice(m.index + m[0].length - 1).replace(/^\(/, "join("))[0] ?? [];
374
+ const literals = args.filter((a) => a !== null);
375
+ const dir = literals.map(dirOf).find(Boolean);
376
+ // An alias of a FILE (`join(ROOT, "src", "cli", "index.ts")`) is a literal path, not a dir.
377
+ if (!dir || /\.[\w-]+$/.test(literals.join("/")))
378
+ continue;
379
+ // Any later use of the alias (join(SRC, name), readdirSync(SRC), `${SRC}/…`) reaches the whole dir.
380
+ const uses = text.slice(m.index + m[0].length).match(new RegExp(`(?<![\\w$.])${m[1].replace(/\$/g, "\\$")}(?![\\w$])`));
381
+ if (uses)
382
+ out.add(dir);
383
+ }
384
+ return sorted(out);
385
+ }
386
+ export function testDependencyModel(root, files, graph = buildImportGraph(root, files)) {
387
+ const available = new Set(files);
388
+ const read = (file) => { try {
389
+ return readFileSync(join(root, file), "utf8");
390
+ }
391
+ catch {
392
+ return "";
393
+ } };
394
+ const helpers = (test) => [test, ...[...graph(test)].filter((f) => available.has(f) && !f.startsWith("src/")).sort()];
395
+ const textMemo = new Map();
396
+ const testText = (testFile) => {
397
+ const test = norm(testFile);
398
+ let text = textMemo.get(test);
399
+ if (text === undefined)
400
+ textMemo.set(test, text = helpers(test).map(read).join("\n"));
401
+ return text;
402
+ };
403
+ const depMemo = new Map();
404
+ const topDirs = new Set(files.filter((f) => f.includes("/")).map((f) => f.slice(0, f.indexOf("/"))));
405
+ const dependencies = (testFile) => {
406
+ const test = norm(testFile);
407
+ const cached = depMemo.get(test);
408
+ if (cached)
409
+ return cached;
410
+ const deps = new Set(graph(test));
411
+ for (const file of helpers(test)) {
412
+ const text = read(file);
413
+ for (const named of literalRepoPaths(text, file, available)) {
414
+ if (named === test)
415
+ continue;
416
+ deps.add(named);
417
+ for (const dep of graph(named))
418
+ deps.add(dep);
419
+ }
420
+ for (const dir of templatedRepoDirs(text, topDirs)) {
421
+ for (const f of files)
422
+ if (f.startsWith(`${dir}/`) && f !== test)
423
+ deps.add(f);
424
+ }
425
+ }
426
+ depMemo.set(test, deps);
427
+ return deps;
428
+ };
429
+ const spawning = (testFile) => SPAWN_RE.test(testText(testFile));
430
+ const tooling = files.filter((f) => /^tooling\/[^/]+\.mjs$/.test(f));
431
+ const wholeProgram = new Set(files.filter((f) => f.startsWith("src/")));
432
+ for (const t of tooling) {
433
+ wholeProgram.add(t);
434
+ for (const dep of graph(t))
435
+ wholeProgram.add(dep);
436
+ }
437
+ let namers;
438
+ const testNamers = (testFile) => {
439
+ if (!namers) {
440
+ namers = new Map();
441
+ for (const file of files.filter((f) => /^(?:src|tooling)\//.test(f) && CODE_FILE.test(f))) {
442
+ const text = read(file);
443
+ const tokens = [...text.matchAll(PATH_TOKEN_RE)].map((m) => m[0]);
444
+ for (const segs of joinSegmentRuns(text))
445
+ for (let i = 0; i < segs.length; i++)
446
+ tokens.push(segs.slice(i).join("/"));
447
+ for (const raw of tokens) {
448
+ const p = norm(raw).replace(/^(?:\.\.?\/)+/, "");
449
+ if (!p.startsWith("test/"))
450
+ continue;
451
+ let set = namers.get(p);
452
+ if (!set)
453
+ namers.set(p, set = new Set());
454
+ set.add(file);
455
+ }
456
+ }
457
+ }
458
+ return sorted(namers.get(norm(testFile)) ?? []);
459
+ };
460
+ return { dependencies, spawning, testText, wholeProgram, closureHash: closureHash(root, files, graph), testNamers };
461
+ }
462
+ /** Structural hash of the entry closures: every reachable file and its direct
463
+ * edges. Content edits don't change it; a new import or file in the closure does. */
464
+ export function closureHash(root, files, graph = buildImportGraph(root, files)) {
465
+ const available = new Set(files);
466
+ const roots = [...CLOSURE_ROOTS.filter((f) => available.has(f)), ...files.filter((f) => /^tooling\/[^/]+\.mjs$/.test(f))];
467
+ const reach = new Set();
468
+ for (const r of roots) {
469
+ reach.add(r);
470
+ for (const f of graph(r))
471
+ reach.add(f);
472
+ }
473
+ const hash = createHash("sha256");
474
+ for (const file of sorted(reach)) {
475
+ // Each reachable file's own closure: any added/removed edge changes one of them.
476
+ hash.update(`${file}\0${available.has(file) ? "1" : "0"}\0${sorted(graph(file)).join("\0")}\n`);
477
+ }
478
+ return hash.digest("hex");
479
+ }
480
+ /** Repo files with any executed range in V8 coverage reports
481
+ * (`NODE_V8_COVERAGE` JSON: `{ result: [{ url, functions: [{ ranges }] }] }`). */
482
+ export function coverageFiles(reports, root, repoFiles) {
483
+ const out = new Set();
484
+ for (const report of reports) {
485
+ const scripts = report?.result;
486
+ if (!Array.isArray(scripts))
487
+ continue;
488
+ for (const script of scripts) {
489
+ if (typeof script.url !== "string" || !script.url.startsWith("file:"))
490
+ continue;
491
+ if (!script.functions?.some((f) => f.ranges?.some((r) => (r.count ?? 0) > 0)))
492
+ continue;
493
+ let abs;
494
+ try {
495
+ abs = fileURLToPath(script.url.replace(/[?#].*$/, ""));
496
+ }
497
+ catch {
498
+ continue;
499
+ }
500
+ const rel = relative(root, abs);
501
+ if (!rel || rel.startsWith("..") || isAbsolute(rel))
502
+ continue;
503
+ const file = norm(rel);
504
+ if (repoFiles.has(file))
505
+ out.add(file);
506
+ }
507
+ }
508
+ return sorted(out);
509
+ }
510
+ /** Parse `git diff --name-status -z` output; renames/copies yield both paths. */
511
+ function nameStatusPaths(out) {
512
+ const parts = out.split("\0").filter(Boolean);
513
+ const paths = [];
514
+ for (let i = 0; i < parts.length;) {
515
+ const status = parts[i++];
516
+ const n = /^[RC]/.test(status) ? 2 : 1;
517
+ for (let k = 0; k < n && i < parts.length; k++)
518
+ paths.push(parts[i++]);
519
+ }
520
+ return paths;
521
+ }
522
+ function hasHead(root) {
523
+ try {
524
+ git(root, ["rev-parse", "--verify", "-q", "HEAD"]);
525
+ return true;
526
+ }
527
+ catch {
528
+ return false;
529
+ }
530
+ }
531
+ /** Changed files for a verify run. `worktree` (default): staged + unstaged +
532
+ * untracked; `staged`: the index only; `base`: merge-base(ref, HEAD)..HEAD
533
+ * plus the working tree. */
534
+ export function changedFiles(root, mode = { kind: "worktree" }) {
535
+ const diff = (...args) => nameStatusPaths(git(root, ["diff", "--name-status", "-z", "-M", ...args]));
536
+ const untracked = () => git(root, ["ls-files", "-z", "--others", "--exclude-standard"]).split("\0").filter(Boolean);
537
+ const paths = [];
538
+ if (mode.kind === "staged") {
539
+ paths.push(...diff("--cached"));
540
+ }
541
+ else {
542
+ if (mode.kind === "base") {
543
+ const base = git(root, ["merge-base", mode.ref, "HEAD"]).trim();
544
+ paths.push(...diff(base, "HEAD"));
545
+ }
546
+ if (hasHead(root))
547
+ paths.push(...diff("HEAD"));
548
+ else
549
+ paths.push(...diff("--cached"), ...diff());
550
+ paths.push(...untracked());
551
+ }
552
+ return sorted(paths.map(norm));
553
+ }
554
+ /** Load an impact map; a missing file is `null`, a malformed one throws. A
555
+ * legacy `test: string[]` entry loads as incomplete (never trusted). */
556
+ export function loadImpactMap(path) {
557
+ if (!existsSync(path))
558
+ return null;
559
+ const raw = JSON.parse(readFileSync(path, "utf8"));
560
+ const commit = typeof raw?.commit === "string" ? raw.commit : "";
561
+ const bad = () => new Error(`invalid impact map ${path}: expected { version, commit, map: { test: { files, complete, commit, closureHash } } }`);
562
+ if (!raw?.map || typeof raw.map !== "object" || Array.isArray(raw.map))
563
+ throw bad();
564
+ const strings = (v) => Array.isArray(v) && v.every((s) => typeof s === "string");
565
+ const map = {};
566
+ for (const [test, value] of Object.entries(raw.map)) {
567
+ if (strings(value)) {
568
+ map[norm(test)] = { files: value.map(norm), complete: false, commit, closureHash: "" };
569
+ continue;
570
+ }
571
+ const e = value;
572
+ if (!e || typeof e !== "object" || !strings(e.files))
573
+ throw bad();
574
+ map[norm(test)] = {
575
+ files: e.files.map(norm),
576
+ complete: e.complete === true,
577
+ commit: typeof e.commit === "string" ? e.commit : commit,
578
+ closureHash: typeof e.closureHash === "string" ? e.closureHash : "",
579
+ };
580
+ }
581
+ return {
582
+ version: Number(raw.version) || 0, commit,
583
+ ...(typeof raw.generatedAt === "string" ? { generatedAt: raw.generatedAt } : {}),
584
+ ...(typeof raw.closureHash === "string" ? { closureHash: raw.closureHash } : {}),
585
+ map,
586
+ };
587
+ }
588
+ //# sourceMappingURL=testSelection.js.map
@@ -111,12 +111,12 @@ export type Resource = z.infer<typeof ResourceSchema>;
111
111
  export declare const EdgeType: z.ZodEnum<{
112
112
  depends_on: "depends_on";
113
113
  calls: "calls";
114
+ references: "references";
114
115
  imports: "imports";
115
116
  contains: "contains";
116
117
  implements: "implements";
117
118
  supersedes: "supersedes";
118
119
  related_to: "related_to";
119
- references: "references";
120
120
  provides: "provides";
121
121
  belongs_to: "belongs_to";
122
122
  implemented_by: "implemented_by";
@@ -173,12 +173,12 @@ export declare const EdgeSchema: z.ZodObject<{
173
173
  type: z.ZodEnum<{
174
174
  depends_on: "depends_on";
175
175
  calls: "calls";
176
+ references: "references";
176
177
  imports: "imports";
177
178
  contains: "contains";
178
179
  implements: "implements";
179
180
  supersedes: "supersedes";
180
181
  related_to: "related_to";
181
- references: "references";
182
182
  provides: "provides";
183
183
  belongs_to: "belongs_to";
184
184
  implemented_by: "implemented_by";
@@ -783,12 +783,12 @@ export declare const SCHEMAS: {
783
783
  type: z.ZodEnum<{
784
784
  depends_on: "depends_on";
785
785
  calls: "calls";
786
+ references: "references";
786
787
  imports: "imports";
787
788
  contains: "contains";
788
789
  implements: "implements";
789
790
  supersedes: "supersedes";
790
791
  related_to: "related_to";
791
- references: "references";
792
792
  provides: "provides";
793
793
  belongs_to: "belongs_to";
794
794
  implemented_by: "implemented_by";
@@ -147,12 +147,12 @@ export const ResourceSchema = z.object({
147
147
  export const EdgeType = z.enum([
148
148
  "depends_on",
149
149
  "calls",
150
+ "references",
150
151
  "imports",
151
152
  "contains",
152
153
  "implements",
153
154
  "supersedes",
154
155
  "related_to",
155
- "references",
156
156
  "provides",
157
157
  "belongs_to",
158
158
  "implemented_by",
@@ -29,10 +29,10 @@ export interface DiffAnalysis {
29
29
  removedDeps: string[];
30
30
  addedLines: number;
31
31
  removedLines: number;
32
- /** Added line bodies (the "+" content, marker stripped) per file — every file,
33
- * not just substantive ones. The text veto's symbol/pattern tiers match against
34
- * — call sites, not just declarations, which addedSymbols can't see. Keyed by
35
- * the same (new-path) key as perFile. */
32
+ /** Added line bodies (content after the `+`) per file, for EVERY file, not only
33
+ * substantive ones. The text veto's symbol/pattern tiers match call sites, which
34
+ * `addedSymbols` cannot see. Keyed by the same new-side path as the per-file
35
+ * declarations map. */
36
36
  addedLinesByFile: Map<string, string[]>;
37
37
  }
38
38
  /** Undo git's C-style path quoting. Even with core.quotePath=false, git quotes a