@davesheffer/hunch 1.45.1 → 1.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/cli/index.js +165 -33
  2. package/dist/cli/invocation.d.ts +14 -0
  3. package/dist/cli/invocation.js +23 -0
  4. package/dist/cli/serve.d.ts +2 -0
  5. package/dist/cli/serve.js +2 -1
  6. package/dist/cli/verify.d.ts +28 -0
  7. package/dist/cli/verify.js +265 -0
  8. package/dist/constitution/experiment.d.ts +1 -1
  9. package/dist/core/agenthook.d.ts +1 -0
  10. package/dist/core/agenthook.js +82 -26
  11. package/dist/core/boundedSpawn.d.ts +57 -0
  12. package/dist/core/boundedSpawn.js +321 -0
  13. package/dist/core/config.d.ts +6 -0
  14. package/dist/core/config.js +1 -0
  15. package/dist/core/delivery.d.ts +10 -0
  16. package/dist/core/delivery.js +117 -9
  17. package/dist/core/migrate.d.ts +10 -1
  18. package/dist/core/migrate.js +17 -6
  19. package/dist/core/pipeline.d.ts +9 -2
  20. package/dist/core/pipeline.js +13 -3
  21. package/dist/core/procstart.d.ts +17 -3
  22. package/dist/core/procstart.js +26 -5
  23. package/dist/core/projectDna.js +11 -1
  24. package/dist/core/relativeImports.js +6 -2
  25. package/dist/core/taskSelection.d.ts +1 -0
  26. package/dist/core/taskSelection.js +132 -11
  27. package/dist/core/testSelection.d.ts +122 -0
  28. package/dist/core/testSelection.js +588 -0
  29. package/dist/core/whyRecord.d.ts +47 -0
  30. package/dist/core/whyRecord.js +105 -0
  31. package/dist/extractors/git.d.ts +32 -0
  32. package/dist/extractors/git.js +206 -94
  33. package/dist/extractors/indexer.js +42 -6
  34. package/dist/extractors/languages.d.ts +9 -0
  35. package/dist/extractors/languages.js +28 -0
  36. package/dist/extractors/parse.d.ts +20 -0
  37. package/dist/extractors/parse.js +81 -5
  38. package/dist/extractors/workspaces.js +3 -1
  39. package/dist/integrations/health.d.ts +6 -0
  40. package/dist/integrations/health.js +33 -12
  41. package/dist/integrations/hooks.d.ts +4 -2
  42. package/dist/integrations/hooks.js +12 -2
  43. package/dist/integrations/mergeDriver.js +5 -3
  44. package/dist/integrations/optout.d.ts +3 -0
  45. package/dist/integrations/optout.js +17 -0
  46. package/dist/integrations/providers.d.ts +6 -2
  47. package/dist/integrations/providers.js +19 -2
  48. package/dist/integrations/team.js +6 -4
  49. package/dist/integrations/uninstall.d.ts +17 -4
  50. package/dist/integrations/uninstall.js +35 -13
  51. package/dist/mcp/server.js +179 -30
  52. package/dist/serve/app.js +9 -1
  53. package/dist/serve/config.js +5 -0
  54. package/dist/serve/writelock.d.ts +6 -5
  55. package/dist/serve/writelock.js +26 -21
  56. package/dist/store/hunchStore.d.ts +12 -1
  57. package/dist/store/hunchStore.js +86 -4
  58. package/dist/store/jsonStore.d.ts +24 -4
  59. package/dist/store/jsonStore.js +75 -26
  60. package/dist/store/stateBinding.js +17 -4
  61. package/dist/synthesis/provider.js +26 -78
  62. package/dist/synthesis/synthesize.js +1 -1
  63. package/package.json +2 -2
  64. package/server.json +2 -2
@@ -69,9 +69,9 @@ export declare const heldLockNonces: Set<string>;
69
69
  * - `stale` — provably not the owner's process any more: take it over.
70
70
  * - `live` — authoritative, however old the file looks: never steal it.
71
71
  * - `unproven` — a live pid whose process instance no token can compare (macOS,
72
- * Windows, a legacy record). Treated as live; a caller with a
73
- * better probe may still disprove it (src/serve/writelock.ts
74
- * asks the OS when that process was created, issue #287).
72
+ * Windows, a legacy record). Treated as live unless
73
+ * `createdAfterLock` proves the pid reused (both locks ask the
74
+ * OS when that process was created, issues #287, #457).
75
75
  * - `age` — nothing is proven; fall back to the caller's own age rule. */
76
76
  export type SameHostVerdict = "stale" | "live" | "unproven" | "age";
77
77
  /** The parts of a lock record this decision reads. Both lock formats supply it. */
@@ -102,4 +102,18 @@ export declare function judgeSameHostOwner(owner: SameHostOwner, env: SameHostEn
102
102
  * other platform: linux derives `lstart` from boot time, which a forward clock
103
103
  * step shifts for live processes too (see the header). */
104
104
  export declare function processStartedAt(pid: number): number | null;
105
+ /** How much later than the lock's recorded `at` the pid's current process must
106
+ * have been created before it is proven not to be the owner. A genuine owner
107
+ * started BEFORE it took the lock; the slack only absorbs clock granularity. */
108
+ export declare const CREATED_AFTER_SLACK_MS = 5000;
109
+ /** Is the process now holding the owner's pid provably newer than the lock?
110
+ * The one rule both `src/serve/writelock.ts` and jsonStore's `.rmw-lock` apply
111
+ * to an "unproven" verdict (issues #287, #457). Unreadable either way — no `at`
112
+ * recorded (a legacy record), or no creation time from the OS — means not
113
+ * proven, and a live pid keeps its lock. SPAWNS through `processStartedAt`, so
114
+ * callers consult it only for a lock that already looks stale by age. */
115
+ export declare function createdAfterLock(owner: {
116
+ pid: number;
117
+ at?: string;
118
+ }, startedAt?: (pid: number) => number | null): boolean;
105
119
  export {};
@@ -26,8 +26,9 @@
26
26
  * OFF LINUX (issue #287). Only linux exposes a spawn-free, stable process-instance
27
27
  * id (`/proc/<pid>/stat` field 22 plus the boot id and the pid/time namespaces), so
28
28
  * on macOS and Windows a DIFFERENT live process that recycled the owner's pid is
29
- * "unproven" here, which this table treats as live. The write lock closes that
30
- * forever-deadlock on its own rare path, once a lock already looks stale: it asks
29
+ * "unproven" here, which this table treats as live. Both locks close that
30
+ * forever-deadlock on their own rare path, once a lock already looks stale
31
+ * (`createdAfterLock`; the `.rmw-lock` since issue #457): they ask
31
32
  * the OS when the process now holding the pid was CREATED (`processStartedAt`, a
32
33
  * spawn) and compares that with the `at` the owner recorded when it took the lock.
33
34
  * A process created after the lock was taken cannot have taken it. Both instants
@@ -56,7 +57,7 @@
56
57
  * holder may judge itself against a token that no longer describes it. Tokenless
57
58
  * records (a legacy pre-token hunch, any non-linux writer, an unreadable /proc)
58
59
  * get main's unchanged pid rule — a live same-host pid keeps its lock (unless the
59
- * write lock's creation-time check above proves the pid reused), a dead one is
60
+ * creation-time check above proves the pid reused), a dead one is
60
61
  * stealable — with exactly one exception, the own-pid + foreign-nonce row
61
62
  * described in `judgeSameHostOwner`, which gets the AGE rule instead of main's
62
63
  * "live forever".
@@ -245,8 +246,9 @@ export function judgeSameHostOwner(owner, env) {
245
246
  return "age";
246
247
  // 7. A live pid no token can compare — another process's, or a nonce-less
247
248
  // record of our own pid. Never stolen on a guess: "unproven" is "live" to a
248
- // caller that has nothing better. The write lock asks the OS for that
249
- // process's creation time, which can prove the pid was reused (issue #287).
249
+ // caller that has nothing better. Both locks ask the OS for that process's
250
+ // creation time (`createdAfterLock`), which can prove the pid was reused
251
+ // (issues #287, #457).
250
252
  return "unproven";
251
253
  }
252
254
  /** Wall-clock ms at which the process now holding `pid` was created, from the
@@ -279,4 +281,23 @@ export function processStartedAt(pid) {
279
281
  return null;
280
282
  }
281
283
  }
284
+ /** How much later than the lock's recorded `at` the pid's current process must
285
+ * have been created before it is proven not to be the owner. A genuine owner
286
+ * started BEFORE it took the lock; the slack only absorbs clock granularity. */
287
+ export const CREATED_AFTER_SLACK_MS = 5_000;
288
+ /** Is the process now holding the owner's pid provably newer than the lock?
289
+ * The one rule both `src/serve/writelock.ts` and jsonStore's `.rmw-lock` apply
290
+ * to an "unproven" verdict (issues #287, #457). Unreadable either way — no `at`
291
+ * recorded (a legacy record), or no creation time from the OS — means not
292
+ * proven, and a live pid keeps its lock. SPAWNS through `processStartedAt`, so
293
+ * callers consult it only for a lock that already looks stale by age. */
294
+ export function createdAfterLock(owner, startedAt = processStartedAt) {
295
+ if (owner.at === undefined)
296
+ return false;
297
+ const at = Date.parse(owner.at);
298
+ if (!Number.isFinite(at))
299
+ return false;
300
+ const started = startedAt(owner.pid);
301
+ return started !== null && started > at + CREATED_AFTER_SLACK_MS;
302
+ }
282
303
  //# sourceMappingURL=procstart.js.map
@@ -81,7 +81,17 @@ function gitText(root, args) {
81
81
  function exactCommit(root, ref) {
82
82
  if (!ref.trim() || /[\0\r\n]/.test(ref) || ref.length > 1_024)
83
83
  throw new Error("Git revision is invalid");
84
- const revision = gitText(root, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]);
84
+ let revision;
85
+ try {
86
+ revision = gitText(root, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]);
87
+ }
88
+ catch (error) {
89
+ // Git's own text ("fatal: Needed a single revision") never names the ref (#493);
90
+ // any other failure keeps its original message.
91
+ if (!/Needed a single revision|unknown revision|bad revision/i.test(error.message))
92
+ throw error;
93
+ throw new Error(`Git revision ${JSON.stringify(ref)} does not name a commit in this repository`);
94
+ }
85
95
  if (!GIT_OBJECT.test(revision))
86
96
  throw new Error("Git did not return an exact commit object");
87
97
  return revision;
@@ -26,8 +26,12 @@ export function relativeImportCandidates(fromFile, specifier) {
26
26
  * indexer compatibility; callers that need ambiguity metadata can inspect the
27
27
  * returned matches instead of guessing a different target. */
28
28
  export function resolveRelativeImport(fromFile, specifier, availableFiles) {
29
- const available = new Set(availableFiles);
30
- const matches = relativeImportCandidates(fromFile, specifier).filter((candidate) => available.has(candidate));
29
+ // Bare/non-relative specifiers have no candidates: skip the O(N) file-set copy.
30
+ const candidates = relativeImportCandidates(fromFile, specifier);
31
+ if (candidates.length === 0)
32
+ return { path: null, matches: [] };
33
+ const available = availableFiles instanceof Set ? availableFiles : new Set(availableFiles);
34
+ const matches = candidates.filter((candidate) => available.has(candidate));
31
35
  return { path: matches[0] ?? null, matches };
32
36
  }
33
37
  //# sourceMappingURL=relativeImports.js.map
@@ -59,6 +59,7 @@ export declare function liveSelectionRecords(all: TaskSelectionRecords): TaskSel
59
59
  * or `:start-end` and a possessive `'s`, forward-slashed, repo-relative. A drive-letter root compares
60
60
  * case-insensitively (Windows paths are case-insensitive on every host). */
61
61
  export declare function promptPaths(prompt: string, root?: string): string[];
62
+ export declare function identifierTokens(text: string): Set<string>;
62
63
  /** Score live records against one prompt. Text fields per kind (title + rationale):
63
64
  * decision title/context/decision; bug title/symptom/root_cause; constraint
64
65
  * statement/rationale; finding title/observation. Path anchors: decision
@@ -142,6 +142,88 @@ export function promptPaths(prompt, root) {
142
142
  }
143
143
  return [...out];
144
144
  }
145
+ const ID_PREFIX = "id:";
146
+ const MIN_IDENTIFIER_CHARS = 5;
147
+ const IDENTIFIER_WEIGHT = 2;
148
+ const CAMEL_IDENTIFIER = /(?<![A-Za-z0-9])[A-Za-z0-9]*(?:[a-z][A-Z]|[A-Z]{2,}[a-z])[A-Za-z0-9]*/g;
149
+ const SEPARATED_IDENTIFIER = /(?<![A-Za-z0-9])[A-Za-z][A-Za-z0-9]*(?:[_.\-][A-Za-z0-9]+)+/g;
150
+ const isCompound = (word) => /[a-z][A-Z]|[A-Z]{2,}[a-z]|_/.test(word);
151
+ const camelParts = (word) => word.split(/(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/).length;
152
+ /** Compound identifiers in raw text (camel/Pascal, snake, dotted, kebab), lowercased with
153
+ * separators removed and marked `id:` so they never collide with word tokens. A dotted
154
+ * name also yields its last segment when that is itself compound (`dbo.ShowInForms` ->
155
+ * `id:dboshowinforms`, `id:showinforms`). `lexicalTokens` splits these into common words
156
+ * that the rare-term filter discards; the whole identifier is the rare, discriminating term.
157
+ * STRONG identifiers (3+ camel parts, or `_`/`.` between alphanumerics) may qualify a record
158
+ * alone; two-part camel words (GitHub, useEffect) are common prose and count as one word. */
159
+ function scanIdentifiers(text) {
160
+ const out = new Map();
161
+ const add = (raw, strong) => {
162
+ const id = raw.replace(/[_.\-]/g, "").toLowerCase();
163
+ if (id.length >= MIN_IDENTIFIER_CHARS)
164
+ out.set(ID_PREFIX + id, (out.get(ID_PREFIX + id) ?? false) || strong);
165
+ };
166
+ for (const m of text.match(CAMEL_IDENTIFIER) ?? [])
167
+ add(m, camelParts(m) >= 3);
168
+ for (const m of text.match(SEPARATED_IDENTIFIER) ?? []) {
169
+ if (/^v?\d+(?:\.\d+)+/i.test(m))
170
+ continue; // version numbers
171
+ if (m.includes(".")) {
172
+ const last = m.slice(m.lastIndexOf(".") + 1);
173
+ if (/^[a-z0-9]{1,8}$/.test(last))
174
+ continue; // file extension: paths own those
175
+ add(m, true);
176
+ if (isCompound(last))
177
+ add(last, true);
178
+ }
179
+ else if (m.includes("_")) {
180
+ const parts = m.split(/[_\-]/);
181
+ if (m.replace(/[_\-]/g, "").length >= 6 && parts.some((part) => part.length >= 3))
182
+ add(m, true);
183
+ }
184
+ else if (/(?<!^|-)[A-Z0-9]/.test(m)) {
185
+ add(m, false); // kebab: a capital/digit past the first letter of a part; plain prose ("Pre-commit") is not an identifier
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+ export function identifierTokens(text) {
191
+ return new Set(scanIdentifiers(text).keys());
192
+ }
193
+ /** Word -> the `id:` terms of the camel compounds the prompt splits it out of (PowerShell ->
194
+ * power, shell; GitHubActions -> git, hub, actions), sorted so prompt word order never matters.
195
+ * A word the prompt also uses on its own belongs to no compound. */
196
+ function camelCompoundGroups(text) {
197
+ const groups = new Map();
198
+ const compounds = text.match(CAMEL_IDENTIFIER) ?? [];
199
+ if (!compounds.length)
200
+ return groups;
201
+ const standalone = lexicalTokens(text.replace(CAMEL_IDENTIFIER, " "));
202
+ for (const m of compounds) {
203
+ const key = ID_PREFIX + m.toLowerCase();
204
+ for (const word of lexicalTokens(m)) {
205
+ if (standalone.has(word))
206
+ continue;
207
+ const keys = groups.get(word) ?? [];
208
+ if (!keys.includes(key))
209
+ keys.push(key);
210
+ groups.set(word, keys.sort());
211
+ }
212
+ }
213
+ return groups;
214
+ }
215
+ /** A pasted stack frame: `at fn (file:line:col)`, `at file:line:col`, `at X (<anonymous>)`, `at X (node:...)`.
216
+ * Prose that merely starts with "at" ("at startup the lock goes stale") has no such tail. */
217
+ const FRAME_LINE = /^\s*at\s+\S+.*(?::\d+:\d+\)?|\(<anonymous>\)|\(node:[^)]*\))\s*$/;
218
+ /** Text whose words count: every line, unless the prompt holds an actual trace (2+ frame
219
+ * lines), whose frames name library internals (process, tick, sync), not the task. A Node
220
+ * warning's `(node:<pid>)` prefix is noise either way; the words after it stay. */
221
+ function promptWordText(prompt) {
222
+ const lines = prompt.split(/\r?\n/);
223
+ const trace = lines.filter((line) => FRAME_LINE.test(line)).length >= 2;
224
+ return (trace ? lines.filter((line) => !FRAME_LINE.test(line)) : lines).join("\n").replace(/\(node:\d+\)/g, " ");
225
+ }
226
+ const termTokens = (text) => new Set([...lexicalTokens(text), ...identifierTokens(text)]);
145
227
  /** Score live records against one prompt. Text fields per kind (title + rationale):
146
228
  * decision title/context/decision; bug title/symptom/root_cause; constraint
147
229
  * statement/rationale; finding title/observation. Path anchors: decision
@@ -149,7 +231,10 @@ export function promptPaths(prompt, root) {
149
231
  * ties break on the builder profile's kind/severity score in delivery.ts. */
150
232
  export function selectForTask(records, promptText, opts = {}) {
151
233
  const k = Math.max(0, opts.k ?? DEFAULT_K);
152
- const taskTerms = lexicalTokens(promptText ?? "");
234
+ // Stack-frame lines name library APIs (writeFileSync, processTicksAndRejections), not the task:
235
+ // when the prompt holds a real trace, neither their words nor their identifiers count.
236
+ const promptIdentifiers = promptWordText(promptText ?? "");
237
+ const taskTerms = new Set([...lexicalTokens(promptIdentifiers), ...identifierTokens(promptIdentifiers)]);
153
238
  // Without an existence check (pure/offline) only a slashed token is trusted as a
154
239
  // path: "e.g.", "Node.js" or "v1.2" would otherwise match a broad glob.
155
240
  const paths = promptPaths(promptText ?? "", opts.root).filter((p) => opts.pathExists ? opts.pathExists(p) : p.includes("/"));
@@ -159,7 +244,7 @@ export function selectForTask(records, promptText, opts = {}) {
159
244
  // Tokenize every record once: the same tokens feed the document frequency and the score.
160
245
  const candidates = [];
161
246
  const consider = (id, kind, title, text, anchors, priority, listable) => {
162
- candidates.push({ id, kind, title, tokens: lexicalTokens(text.join(" ")), anchors, priority, listable });
247
+ candidates.push({ id, kind, title, tokens: termTokens(text.join(" ")), anchors, priority, listable });
163
248
  };
164
249
  for (const c of records.constraints) {
165
250
  consider(c.id, "constraint", c.statement, [c.statement, c.rationale], c.scope, base.constraints + SEVERITY[c.severity] * 10 + (c.provenance.confidence ?? 0), c.severity !== "blocking");
@@ -174,20 +259,56 @@ export function selectForTask(records, promptText, opts = {}) {
174
259
  consider(f.id, "finding", f.title, [f.title, f.observation], f.affected_files, base.findings + SEVERITY[f.severity] * 10, true);
175
260
  }
176
261
  const dfCap = Math.max(MIN_DF_CAP, Math.ceil(DF_CAP_RATIO * candidates.length));
177
- const rareTerms = [...taskTerms].filter((term) => candidates.filter((c) => c.tokens.has(term)).length <= dfCap);
262
+ const df = new Map();
263
+ for (const c of candidates)
264
+ for (const term of c.tokens)
265
+ df.set(term, (df.get(term) ?? 0) + 1);
266
+ // A camel compound in many records is a brand (GitHub, PowerShell, GitHubActions): in a record
267
+ // holding it, its parts count as one word. A near-unique one (df <= MIN_DF_CAP, e.g. QuotaReaper)
268
+ // names this project, and in a record without the compound a part is an ordinary word.
269
+ const wordGroups = new Map();
270
+ for (const [word, keys] of camelCompoundGroups(promptIdentifiers)) {
271
+ const common = keys.filter((key) => (df.get(key) ?? 0) > MIN_DF_CAP);
272
+ if (common.length)
273
+ wordGroups.set(word, common);
274
+ }
275
+ const strongIds = new Set([...scanIdentifiers(promptIdentifiers)].filter(([, strong]) => strong).map(([term]) => term));
276
+ const rareTerms = new Map();
277
+ for (const term of taskTerms) {
278
+ const n = df.get(term) ?? 0;
279
+ if (n > dfCap)
280
+ continue;
281
+ // Integer weights so equal sums tie exactly and fall through to the priority tiebreak.
282
+ const idf = Math.round(Math.log(1 + candidates.length / Math.max(1, n)) * 1000);
283
+ // Only a strong identifier that is near-unique (df <= MIN_DF_CAP) counts, at x2, and qualifies alone.
284
+ // Weak ones (GitHub, useEffect) add nothing: their parts are already counted as words.
285
+ const strong = strongIds.has(term) && n <= MIN_DF_CAP;
286
+ if (term.startsWith(ID_PREFIX) && !strong)
287
+ continue;
288
+ rareTerms.set(term, { weight: strong ? idf * IDENTIFIER_WEIGHT : idf, strong });
289
+ }
178
290
  const scored = [];
179
291
  for (const { tokens, anchors, ...rest } of candidates) {
180
- let terms = 0;
181
- for (const term of rareTerms)
182
- if (tokens.has(term))
183
- terms++;
292
+ const words = new Set();
293
+ let identifiers = 0;
294
+ let termScore = 0;
295
+ for (const [term, { weight, strong }] of rareTerms) {
296
+ if (!tokens.has(term))
297
+ continue;
298
+ if (strong)
299
+ identifiers++;
300
+ else
301
+ words.add(wordGroups.get(term)?.find((key) => tokens.has(key)) ?? term);
302
+ termScore += weight;
303
+ }
184
304
  const pathHits = paths.filter((p) => anchors.some((anchor) => pathMatchesGlob(p, anchor))).length;
185
- if (pathHits > 0 || terms >= MIN_DISTINCT_TERMS)
186
- scored.push({ ...rest, pathHits, terms });
305
+ // One rare identifier alone qualifies (it counts double); a lone rare word does not.
306
+ if (pathHits > 0 || words.size + IDENTIFIER_WEIGHT * identifiers >= MIN_DISTINCT_TERMS)
307
+ scored.push({ ...rest, pathHits, termScore });
187
308
  }
188
309
  // A record anchored to more of the files the prompt names outranks one that
189
- // shares a single (often hub) file with it; rare terms break the remaining ties.
190
- scored.sort((a, b) => b.pathHits - a.pathHits || b.terms - a.terms || b.priority - a.priority || a.id.localeCompare(b.id));
310
+ // shares a single (often hub) file with it; idf-weighted rare terms break the remaining ties.
311
+ scored.sort((a, b) => b.pathHits - a.pathHits || b.termScore - a.termScore || b.priority - a.priority || a.id.localeCompare(b.id));
191
312
  return {
192
313
  qualifying: scored.map((s) => s.id),
193
314
  top: scored.filter((s) => s.listable).slice(0, k).map(({ id, kind, title }) => ({ id, kind, title })),
@@ -0,0 +1,122 @@
1
+ /** One test's coverage record. `complete` = the test file passed (ran to the
2
+ * end, so its coverage is whole); `closureHash` = the entry-closure hash at
3
+ * refresh time; `commit` = HEAD when this entry was recorded. */
4
+ export interface ImpactEntry {
5
+ files: string[];
6
+ complete: boolean;
7
+ commit: string;
8
+ closureHash: string;
9
+ }
10
+ export interface ImpactMap {
11
+ version: number;
12
+ commit: string;
13
+ generatedAt?: string;
14
+ closureHash?: string;
15
+ /** test file → coverage entry (repo-relative posix). */
16
+ map: Record<string, ImpactEntry>;
17
+ }
18
+ export interface SelectionInput {
19
+ changedFiles: string[];
20
+ allTests: string[];
21
+ impactMap?: ImpactMap;
22
+ /** Static dependencies of a test: transitive imports plus the closures of
23
+ * repo paths its text names (see `testDependencyModel`). */
24
+ importGraph: (testFile: string) => Set<string>;
25
+ fullTriggers?: string[];
26
+ /** Extra per-test globs: a changed file matching one selects that test. */
27
+ gateMaps?: Record<string, string[]>;
28
+ /** Source text of a test (and its non-src helpers), for path/basename mentions. */
29
+ testText?: (testFile: string) => string;
30
+ /** Does the test start a child process (directly or through a helper)? */
31
+ spawning?: (testFile: string) => boolean;
32
+ /** Every file a spawned child could execute (all src/ plus tooling closures). */
33
+ wholeProgram?: Iterable<string>;
34
+ /** Entry-closure hash computed now; a map entry is trusted only if it matches. */
35
+ closureHash?: string;
36
+ /** src/ and tooling/ files whose text names a test file by repo path. */
37
+ testNamers?: (testFile: string) => string[];
38
+ }
39
+ export interface Selection {
40
+ mode: "selected" | "full" | "none";
41
+ tests: string[];
42
+ reasons: Record<string, string[]>;
43
+ fullReason?: string;
44
+ unattributed: string[];
45
+ /** Tests whose impact-map entry attributed a change or narrowed a spawning test. */
46
+ mapUsed: string[];
47
+ }
48
+ export declare const DEFAULT_FULL_TRIGGERS: readonly string[];
49
+ /** Repo-root markdown (README.md, CHANGELOG.md, ...) is the only non-code change
50
+ * that can skip the full suite: it selects tests that mention it, else nothing. */
51
+ export declare function isDocsOnly(file: string): boolean;
52
+ /** Selected tests above this share of the suite run the full suite instead. */
53
+ export declare const MAX_SELECTED_SHARE = 0.6;
54
+ export declare const isMetaTest: (text: string) => boolean;
55
+ /** A map entry may narrow a spawning test only when it is complete and was
56
+ * recorded against the same entry closure as now. */
57
+ export declare function entryTrusted(entry: ImpactEntry | undefined, closureHash: string | undefined): boolean;
58
+ export declare function selectTests(input: SelectionInput): Selection;
59
+ /** Tracked + untracked (non-ignored) repo files, repo-relative posix, sorted. */
60
+ export declare function listRepoFiles(root: string): string[];
61
+ export declare function discoverTests(files: string[], glob?: string): string[];
62
+ /** Static imports, re-exports, `import()`/`require()` with literal specifiers. */
63
+ export declare function importSpecifiers(source: string): string[];
64
+ /** Transitive relative-import closure per file, memoized for this call. An
65
+ * unresolvable relative specifier (e.g. a deleted file) records every
66
+ * candidate path, so the deletion stays attributable to its importers. */
67
+ export declare function buildImportGraph(root: string, files?: string[]): (file: string) => Set<string>;
68
+ /** Entries whose import closure (edges included) keys map-entry trust. */
69
+ export declare const CLOSURE_ROOTS: readonly string[];
70
+ /** String-literal arguments of each `join(`/`resolve(` call, found with a
71
+ * balanced-paren scan so nested calls (`join(process.cwd(), "src")`) parse. */
72
+ export declare function joinSegmentRuns(text: string): string[][];
73
+ /** Repo files a test's text names: path-like tokens anywhere (string literals,
74
+ * `--eval` import specifiers, template strings) and `join(…, "src", "x.ts")`
75
+ * segment runs, resolved against the REPO ROOT (and the test's own dir for
76
+ * `./`/`../` tokens), with `.js`→`.ts` mapping. */
77
+ export declare function literalRepoPaths(text: string, fromFile: string, available: Set<string>): string[];
78
+ /** Arguments of each `join(`/`resolve(` call: a plain string literal's value, or
79
+ * `null` for anything computed (identifiers, calls, `${}` templates). */
80
+ export declare function joinCallArgs(text: string): (string | null)[][];
81
+ /** Top-level repo dirs a text reaches through a COMPUTED path: `` `../src/${p}` ``,
82
+ * `"src/" + p`, `join(root, "src", p)`, or a later use of an alias like
83
+ * `const SRC = join(ROOT, "src")`. A test doing that may read any file under
84
+ * the dir, so it depends on all of them. A computed segment right after the
85
+ * root (`` `../${dir}/x` ``, `"../" + dir`, `resolve(__dirname, "..", dir)`,
86
+ * `` `${ROOT}/${dir}` ``) may be ANY top-level dir (fail closed). */
87
+ export declare function templatedRepoDirs(text: string, topDirs: Set<string>): string[];
88
+ export interface TestDependencyModel {
89
+ /** Static imports ∪ closures of repo paths named in the test or its helpers. */
90
+ dependencies: (testFile: string) => Set<string>;
91
+ spawning: (testFile: string) => boolean;
92
+ /** Text of the test plus its non-src static closure (helpers). */
93
+ testText: (testFile: string) => string;
94
+ /** All src/ files plus the closures of every tooling/*.mjs. */
95
+ wholeProgram: Set<string>;
96
+ /** Hash of the import closures (edges) of CLOSURE_ROOTS and tooling/*.mjs. */
97
+ closureHash: string;
98
+ /** src/ and tooling/ code files whose text names a test file by repo path. */
99
+ testNamers: (testFile: string) => string[];
100
+ }
101
+ export declare function testDependencyModel(root: string, files: string[], graph?: (file: string) => Set<string>): TestDependencyModel;
102
+ /** Structural hash of the entry closures: every reachable file and its direct
103
+ * edges. Content edits don't change it; a new import or file in the closure does. */
104
+ export declare function closureHash(root: string, files: string[], graph?: (file: string) => Set<string>): string;
105
+ /** Repo files with any executed range in V8 coverage reports
106
+ * (`NODE_V8_COVERAGE` JSON: `{ result: [{ url, functions: [{ ranges }] }] }`). */
107
+ export declare function coverageFiles(reports: unknown[], root: string, repoFiles: Set<string>): string[];
108
+ export type ChangeMode = {
109
+ kind: "worktree";
110
+ } | {
111
+ kind: "staged";
112
+ } | {
113
+ kind: "base";
114
+ ref: string;
115
+ };
116
+ /** Changed files for a verify run. `worktree` (default): staged + unstaged +
117
+ * untracked; `staged`: the index only; `base`: merge-base(ref, HEAD)..HEAD
118
+ * plus the working tree. */
119
+ export declare function changedFiles(root: string, mode?: ChangeMode): string[];
120
+ /** Load an impact map; a missing file is `null`, a malformed one throws. A
121
+ * legacy `test: string[]` entry loads as incomplete (never trusted). */
122
+ export declare function loadImpactMap(path: string): ImpactMap | null;