@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.
Files changed (53) hide show
  1. package/dist/cli/index.js +139 -32
  2. package/dist/cli/invocation.d.ts +14 -0
  3. package/dist/cli/invocation.js +23 -0
  4. package/dist/cli/verify.d.ts +28 -0
  5. package/dist/cli/verify.js +265 -0
  6. package/dist/constitution/experiment.d.ts +1 -1
  7. package/dist/core/agenthook.js +26 -6
  8. package/dist/core/boundedSpawn.d.ts +57 -0
  9. package/dist/core/boundedSpawn.js +321 -0
  10. package/dist/core/config.d.ts +6 -0
  11. package/dist/core/config.js +1 -0
  12. package/dist/core/delivery.d.ts +6 -1
  13. package/dist/core/delivery.js +71 -5
  14. package/dist/core/migrate.d.ts +10 -1
  15. package/dist/core/migrate.js +17 -6
  16. package/dist/core/projectDna.js +11 -1
  17. package/dist/core/relativeImports.js +6 -2
  18. package/dist/core/taskSelection.js +48 -6
  19. package/dist/core/testSelection.d.ts +122 -0
  20. package/dist/core/testSelection.js +588 -0
  21. package/dist/extractors/git.js +32 -31
  22. package/dist/extractors/indexer.js +42 -6
  23. package/dist/extractors/languages.d.ts +9 -0
  24. package/dist/extractors/languages.js +28 -0
  25. package/dist/extractors/parse.d.ts +20 -0
  26. package/dist/extractors/parse.js +81 -5
  27. package/dist/extractors/workspaces.js +3 -1
  28. package/dist/integrations/health.d.ts +6 -0
  29. package/dist/integrations/health.js +33 -12
  30. package/dist/integrations/hooks.d.ts +4 -2
  31. package/dist/integrations/hooks.js +12 -2
  32. package/dist/integrations/mergeDriver.js +5 -3
  33. package/dist/integrations/optout.d.ts +3 -0
  34. package/dist/integrations/optout.js +17 -0
  35. package/dist/integrations/providers.d.ts +6 -2
  36. package/dist/integrations/providers.js +19 -2
  37. package/dist/integrations/team.js +4 -2
  38. package/dist/integrations/uninstall.d.ts +10 -4
  39. package/dist/integrations/uninstall.js +22 -18
  40. package/dist/mcp/server.js +47 -8
  41. package/dist/serve/app.js +9 -1
  42. package/dist/serve/config.js +5 -0
  43. package/dist/serve/writelock.d.ts +3 -1
  44. package/dist/serve/writelock.js +17 -3
  45. package/dist/store/hunchStore.d.ts +2 -1
  46. package/dist/store/hunchStore.js +8 -5
  47. package/dist/store/jsonStore.d.ts +13 -3
  48. package/dist/store/jsonStore.js +37 -13
  49. package/dist/store/stateBinding.js +17 -4
  50. package/dist/synthesis/provider.js +26 -78
  51. package/dist/synthesis/synthesize.js +1 -1
  52. package/package.json +2 -2
  53. package/server.json +2 -2
@@ -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
@@ -190,6 +190,39 @@ function scanIdentifiers(text) {
190
190
  export function identifierTokens(text) {
191
191
  return new Set(scanIdentifiers(text).keys());
192
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
+ }
193
226
  const termTokens = (text) => new Set([...lexicalTokens(text), ...identifierTokens(text)]);
194
227
  /** Score live records against one prompt. Text fields per kind (title + rationale):
195
228
  * decision title/context/decision; bug title/symptom/root_cause; constraint
@@ -199,9 +232,9 @@ const termTokens = (text) => new Set([...lexicalTokens(text), ...identifierToken
199
232
  export function selectForTask(records, promptText, opts = {}) {
200
233
  const k = Math.max(0, opts.k ?? DEFAULT_K);
201
234
  // Stack-frame lines name library APIs (writeFileSync, processTicksAndRejections), not the task:
202
- // keep their words, but extract no identifiers from them.
203
- const promptIdentifiers = (promptText ?? "").split(/\r?\n/).filter((line) => !/^\s*at\s+\S/.test(line) && !/node:internal|\(node:/.test(line)).join("\n");
204
- const taskTerms = new Set([...lexicalTokens(promptText ?? ""), ...identifierTokens(promptIdentifiers)]);
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)]);
205
238
  // Without an existence check (pure/offline) only a slashed token is trusted as a
206
239
  // path: "e.g.", "Node.js" or "v1.2" would otherwise match a broad glob.
207
240
  const paths = promptPaths(promptText ?? "", opts.root).filter((p) => opts.pathExists ? opts.pathExists(p) : p.includes("/"));
@@ -230,6 +263,15 @@ export function selectForTask(records, promptText, opts = {}) {
230
263
  for (const c of candidates)
231
264
  for (const term of c.tokens)
232
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
+ }
233
275
  const strongIds = new Set([...scanIdentifiers(promptIdentifiers)].filter(([, strong]) => strong).map(([term]) => term));
234
276
  const rareTerms = new Map();
235
277
  for (const term of taskTerms) {
@@ -247,7 +289,7 @@ export function selectForTask(records, promptText, opts = {}) {
247
289
  }
248
290
  const scored = [];
249
291
  for (const { tokens, anchors, ...rest } of candidates) {
250
- let words = 0;
292
+ const words = new Set();
251
293
  let identifiers = 0;
252
294
  let termScore = 0;
253
295
  for (const [term, { weight, strong }] of rareTerms) {
@@ -256,12 +298,12 @@ export function selectForTask(records, promptText, opts = {}) {
256
298
  if (strong)
257
299
  identifiers++;
258
300
  else
259
- words++;
301
+ words.add(wordGroups.get(term)?.find((key) => tokens.has(key)) ?? term);
260
302
  termScore += weight;
261
303
  }
262
304
  const pathHits = paths.filter((p) => anchors.some((anchor) => pathMatchesGlob(p, anchor))).length;
263
305
  // One rare identifier alone qualifies (it counts double); a lone rare word does not.
264
- if (pathHits > 0 || words + IDENTIFIER_WEIGHT * identifiers >= MIN_DISTINCT_TERMS)
306
+ if (pathHits > 0 || words.size + IDENTIFIER_WEIGHT * identifiers >= MIN_DISTINCT_TERMS)
265
307
  scored.push({ ...rest, pathHits, termScore });
266
308
  }
267
309
  // A record anchored to more of the files the prompt names outranks one that
@@ -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;