@davesheffer/hunch 1.46.0 → 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 (56) hide show
  1. package/dist/cli/index.js +151 -321
  2. package/dist/cli/invocation.d.ts +8 -13
  3. package/dist/cli/invocation.js +26 -29
  4. package/dist/constitution/delta.js +2 -2
  5. package/dist/constitution/sourceMutation.js +28 -18
  6. package/dist/core/commitrepair.d.ts +43 -138
  7. package/dist/core/commitrepair.js +76 -167
  8. package/dist/core/correction.js +9 -9
  9. package/dist/core/docanchors.js +9 -9
  10. package/dist/core/drift.js +22 -27
  11. package/dist/core/escalations.d.ts +16 -58
  12. package/dist/core/escalations.js +39 -121
  13. package/dist/core/glob.d.ts +7 -7
  14. package/dist/core/glob.js +12 -11
  15. package/dist/core/groundingLag.d.ts +2 -9
  16. package/dist/core/groundingLag.js +5 -11
  17. package/dist/core/groundingMerge.d.ts +2 -7
  18. package/dist/core/groundingMerge.js +35 -54
  19. package/dist/core/paths.d.ts +17 -24
  20. package/dist/core/paths.js +37 -43
  21. package/dist/core/refrepair.d.ts +2 -1
  22. package/dist/core/refrepair.js +4 -4
  23. package/dist/core/repairqueue.d.ts +17 -34
  24. package/dist/core/repairqueue.js +48 -98
  25. package/dist/core/types.d.ts +3 -3
  26. package/dist/core/types.js +1 -1
  27. package/dist/extractors/diff.d.ts +4 -4
  28. package/dist/extractors/diff.js +25 -29
  29. package/dist/extractors/git.d.ts +8 -23
  30. package/dist/extractors/git.js +45 -64
  31. package/dist/extractors/helm.d.ts +10 -0
  32. package/dist/extractors/helm.js +43 -59
  33. package/dist/extractors/indexer.d.ts +6 -0
  34. package/dist/extractors/indexer.js +126 -178
  35. package/dist/extractors/k8sManifest.d.ts +23 -80
  36. package/dist/extractors/k8sManifest.js +319 -690
  37. package/dist/extractors/languages.d.ts +28 -37
  38. package/dist/extractors/languages.js +55 -54
  39. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  40. package/dist/extractors/nativeTreeSitter.js +17 -14
  41. package/dist/extractors/parse.d.ts +7 -13
  42. package/dist/extractors/parse.js +39 -51
  43. package/dist/integrations/claudemd.js +1 -0
  44. package/dist/integrations/gitignore.js +3 -5
  45. package/dist/integrations/hooks.d.ts +9 -14
  46. package/dist/integrations/hooks.js +19 -28
  47. package/dist/integrations/mergeDriver.js +13 -14
  48. package/dist/mcp/server.d.ts +4 -5
  49. package/dist/mcp/server.js +60 -73
  50. package/dist/store/hunchStore.js +12 -18
  51. package/dist/synthesis/provider.d.ts +19 -21
  52. package/dist/synthesis/provider.js +67 -76
  53. package/dist/synthesis/synthesize.d.ts +8 -8
  54. package/dist/synthesis/synthesize.js +32 -49
  55. package/package.json +2 -2
  56. package/server.json +2 -2
@@ -1,21 +1,22 @@
1
1
  import type { Edge } from "../core/types.js";
2
2
  export type ParsedSymbolKind = "function" | "method" | "class" | "interface" | "trait" | "enum" | "type" | "variable" | "file";
3
3
  export interface LanguageSpec {
4
- /** Stable id, also used as the grammar-bundle cache key by parse.ts. */
4
+ /** Stable language id (`typescript`, `python`, `go`, `php`, `yaml`). */
5
5
  id: string;
6
+ /** File suffixes, leading dot included, that this spec handles. */
6
7
  extensions: string[];
7
- /** Cache key parse.ts's bundleFor() uses (one grammar+query pair may serve
8
- * several extensions, e.g. tsx serves both .tsx and .jsx). */
8
+ /** Memoization key for the parser+query bundle. One grammar can serve several
9
+ * extensions, and two specs may share an `id` yet differ here (TS vs TSX). */
9
10
  grammarKey: string;
10
- /** Lazily returns the tree-sitter Language object for this spec. */
11
+ /** Lazily returns the tree-sitter Language object. */
11
12
  loadGrammar(): unknown;
12
- /** Tree-sitter query source capturing every construct this language cares about. */
13
+ /** Tree-sitter query source capturing every construct of interest. */
13
14
  query: string;
14
- /** Node types ascendToDef() walks up to when resolving a name capture's enclosing def. */
15
+ /** Node types the parser climbs to when attaching a name capture to its definition. */
15
16
  defNodeTypes: Set<string>;
16
- /** Query capture-name (ending in ".def") -> the ParsedSymbolKind it represents. */
17
+ /** Symbol kind for each capture name ending in `.def`. */
17
18
  defKindOf: Record<string, ParsedSymbolKind>;
18
- /** Query capture-name (ending in ".name") -> the matching ".def" capture-name. */
19
+ /** Maps each `.name` capture to the `.def` capture it belongs to. */
19
20
  nameToDef: Record<string, string>;
20
21
  /** Optional query binding a local name to an imported module, run with
21
22
  * matches() so captures pair up: @bind.src (module specifier), @bind.local
@@ -32,21 +33,17 @@ export interface LanguageSpec {
32
33
  edgeType: Edge["type"];
33
34
  label: string;
34
35
  }>;
35
- /** Common builtin/stdlib method names. Member calls to these (e.g. `arr.map(...)`)
36
- * must NOT create call edges to unrelated repo symbols that happen to share the
37
- * name (DESIGN: keep the graph clean). */
36
+ /** Common builtin/stdlib method names. A member call whose property is in this
37
+ * set never produces a call edge to a same-named repo symbol. */
38
38
  builtinMethods: Set<string>;
39
39
  /** Direct-call builtins for languages such as PHP. These never become repo
40
40
  * call edges merely because a project symbol shares the runtime name. */
41
41
  builtinFunctions?: Set<string>;
42
- /** Edge type emitted for this language's calls-list entries (e.g. YAML's
43
- * alias->anchor references aren't function calls). Defaults to "calls"
44
- * when omitted — every language before YAML. */
42
+ /** Edge type emitted for this language's call-list entries; defaults to
43
+ * `"calls"`. YAML sets it because alias-to-anchor links are references. */
45
44
  referenceEdgeType?: Edge["type"];
46
- /** Fallback name for a ".def" capture with no paired ".name" capture (e.g.
47
- * a whole-file root symbol with no natural identifier of its own). Omit
48
- * for a language where every ".def" always has a name capture — parse.ts
49
- * then keeps dropping unnamed defs, the current behavior for TS/Python. */
45
+ /** Name for a `.def` capture that has no paired `.name` capture (a whole-file
46
+ * root symbol, say). When omitted, unnamed defs are dropped. */
50
47
  fallbackDefName?: (file: string) => string;
51
48
  /** Ancestor shapes in which an ERROR node is a known limitation of THIS grammar
52
49
  * rather than a real syntax error. parse.ts forgives an error only when some
@@ -64,30 +61,24 @@ export interface LanguageSpec {
64
61
  * every captured value from the original source at the tree's unchanged
65
62
  * offsets. Keep replacements syntax-equivalent outside the grammar defect. */
66
63
  parseErrorRecovery?: (source: string) => string;
67
- /** Patterns whose presence anywhere in the source mean "this isn't actually
68
- * {id} text yet — it's a template that renders to {id} later" (Go/Jinja/Helm
69
- * delimiters in a .yaml file, e.g.). parse.ts still runs the real parse — a
70
- * genuine anchor/symbol elsewhere in the file is still extracted, same as any
71
- * other {id} file — but no longer lets the resulting ERROR nodes fail-close
72
- * the whole-repo scan completeness gate the way toleratedErrorScopes protects
73
- * narrower, per-grammar false positives (issue #33). Keep these narrow: a
74
- * pattern that also matches ordinary, always-valid {id} syntax (e.g. GitHub
75
- * Actions' `${{ }}` expressions) would silently disable the fail-closed
76
- * guarantee for files that were never templated at all. */
64
+ /** Patterns whose presence anywhere in a file's text means the file is a
65
+ * template that renders into this language later (Go/Jinja/Helm delimiters in
66
+ * YAML). The real parse still runs, but the resulting error nodes must not make
67
+ * the file unparseable, so the whole-repo completeness gate does not fail
68
+ * closed. Keep patterns narrow so they never match always-valid syntax such as
69
+ * GitHub Actions `${{ }}` expressions. */
77
70
  templatingMarkers?: readonly RegExp[];
78
- /** Extensions within this language where EVERY file is inherently a template
79
- * regardless of content — unlike templatingMarkers, this never content-sniffs.
80
- * The extension alone means "this file is a template, parseable must never
81
- * become false for it," matching Helm's `_helpers.tpl` convention where the
82
- * file may be pure Go-template text with no YAML structure of its own at all. */
71
+ /** Extensions where every file is a template regardless of content, with no
72
+ * sniffing (Helm's `_helpers.tpl` convention). Such files are never reported
73
+ * unparseable. */
83
74
  alwaysTemplatedExtensions?: readonly string[];
84
75
  }
85
76
  export declare const LANGUAGES: LanguageSpec[];
77
+ /** Every registered extension, de-duplicated, in registry order. */
86
78
  export declare const CODE_EXTENSIONS: string[];
79
+ /** First spec (registry order) with an extension the file name ends with. */
87
80
  export declare function languageFor(file: string): LanguageSpec | null;
88
- /** Prose formats worth drafting a decision from even though no LanguageSpec parses
89
- * them — no grammar, no symbols/edges, just eligible input to synthesis (issue #12). */
81
+ /** Prose formats eligible as synthesis input but never parsed. */
90
82
  export declare const PROSE_EXTENSIONS: string[];
91
- /** Broader than languageFor: "is this worth reasoning about" (synthesis input)
92
- * rather than "can tree-sitter parse this" (symbol/dependency extraction). */
83
+ /** True for parseable code or prose. Deliberately broader than parseability. */
93
84
  export declare function isSubstantive(file: string): boolean;
@@ -1,9 +1,8 @@
1
1
  /**
2
- * Language registry: one LanguageSpec per supported language, consumed by
3
- * parse.ts (tree-sitter grammar/query dispatch), indexer.ts / diff.ts /
4
- * synthesize.ts ("is this a code file?"). Adding a language is a new entry
5
- * here (+ a new tree-sitter-* dependency), not edits scattered across those
6
- * four files.
2
+ * The single language registry: one `LanguageSpec` per supported language. The
3
+ * parser reads grammar and query data from here, and the indexer, diff analysis
4
+ * and synthesis ask it "is this a code file?". Adding a language means adding an
5
+ * entry here plus a grammar dependency.
7
6
  */
8
7
  import { basename } from "node:path";
9
8
  import { loadNativeTreeSitter } from "./nativeTreeSitter.js";
@@ -53,23 +52,36 @@ const TS_BINDING_QUERY = `
53
52
  value: (await_expression (call_expression function: (import) arguments: (arguments . (string) @bind.src .))))
54
53
  `;
55
54
  const TS_BUILTIN_METHODS = new Set([
55
+ // arrays
56
56
  "map", "filter", "forEach", "reduce", "find", "findIndex", "some", "every", "includes",
57
- "push", "pop", "shift", "unshift", "slice", "splice", "concat", "join", "split", "flat", "flatMap",
58
- "indexOf", "lastIndexOf", "keys", "values", "entries", "sort", "reverse", "fill", "at",
57
+ "push", "pop", "shift", "unshift", "slice", "splice", "concat", "join", "split", "flat",
58
+ "flatMap", "indexOf", "lastIndexOf", "keys", "values", "entries", "sort", "reverse", "fill", "at",
59
+ // Map / Set
59
60
  "get", "set", "has", "add", "delete", "clear",
61
+ // Promise
60
62
  "then", "catch", "finally", "all", "race", "resolve", "reject",
63
+ // object basics
61
64
  "toString", "valueOf", "toJSON", "hasOwnProperty",
62
- "replace", "replaceAll", "trim", "trimStart", "trimEnd", "padStart", "padEnd", "startsWith", "endsWith",
63
- "toLowerCase", "toUpperCase", "charAt", "charCodeAt", "substring", "substr", "repeat", "match", "matchAll",
65
+ // strings
66
+ "replace", "replaceAll", "trim", "trimStart", "trimEnd", "padStart", "padEnd", "startsWith",
67
+ "endsWith", "toLowerCase", "toUpperCase", "charAt", "charCodeAt", "substring", "substr",
68
+ "repeat", "match", "matchAll",
69
+ // Function / RegExp / JSON / Date / Array / Object statics
64
70
  "call", "apply", "bind", "test", "exec", "now", "parse", "stringify", "from", "of", "isArray", "assign",
71
+ // console
65
72
  "log", "error", "warn", "info", "debug",
66
73
  ]);
67
74
  const TS_SHARED = {
68
75
  id: "typescript",
69
76
  query: TS_QUERY,
70
77
  defNodeTypes: new Set([
71
- "function_declaration", "generator_function_declaration", "method_definition",
72
- "class_declaration", "interface_declaration", "type_alias_declaration", "variable_declarator",
78
+ "function_declaration",
79
+ "generator_function_declaration",
80
+ "method_definition",
81
+ "class_declaration",
82
+ "interface_declaration",
83
+ "type_alias_declaration",
84
+ "variable_declarator",
73
85
  "public_field_definition",
74
86
  ]),
75
87
  defKindOf: {
@@ -99,10 +111,9 @@ const TYPESCRIPT = {
99
111
  grammarKey: "ts",
100
112
  loadGrammar: () => loadNativeTreeSitter().typescript,
101
113
  };
102
- /** .tsx/.jsx use the TSX grammar variant; everything else in the TS spec uses
103
- * the plain typescript grammar. Both share the same query/def maps/builtins,
104
- * so this is a second LanguageSpec entry with a distinct grammarKey/loadGrammar
105
- * only — not a second `id` (languageFor callers only care about extension match). */
114
+ /** TSX is a second entry that differs from TYPESCRIPT only in grammar and cache
115
+ * key (the `id` is shared). It also overrides the query with TSX_QUERY, widens
116
+ * the tolerated error scopes and adds a recovery pass. */
106
117
  const TSX = {
107
118
  ...TS_SHARED,
108
119
  extensions: [".tsx", ".jsx"],
@@ -124,13 +135,15 @@ const TSX = {
124
135
  parseErrorRecovery: (source) => source.replaceAll("&", "|"),
125
136
  };
126
137
  const PY_QUERY = `
138
+ ;; A class whose body directly holds a def (plain or decorated): the nested def is
139
+ ;; a method. parse.ts keeps the first classification per node, so the generic
140
+ ;; function pattern below does not reclassify it.
127
141
  (class_definition
128
- name: (identifier) @class.name
129
- body: (block
130
- [
131
- (function_definition name: (identifier) @method.name) @method.def
132
- (decorated_definition definition: (function_definition name: (identifier) @method.name) @method.def)
133
- ])) @class.def
142
+ name: (identifier) @class.name
143
+ body: (block [
144
+ (function_definition name: (identifier) @method.name) @method.def
145
+ (decorated_definition definition: (function_definition name: (identifier) @method.name) @method.def)
146
+ ])) @class.def
134
147
  ;; Every class, including one with no directly-nested def: dataclasses, Exception
135
148
  ;; subclasses, Enums, TypedDicts and pydantic models are method-less by design and
136
149
  ;; were invisible to the entire graph (no symbol, no component, no edges), so
@@ -141,20 +154,18 @@ const PY_QUERY = `
141
154
  (function_definition name: (identifier) @fn.name) @fn.def
142
155
  (import_statement name: (dotted_name) @import.src)
143
156
  (import_statement name: (aliased_import name: (dotted_name) @import.src))
144
- (import_from_statement module_name: (dotted_name) @import.src)
145
- (import_from_statement module_name: (relative_import) @import.src)
157
+ (import_from_statement module_name: [(dotted_name) (relative_import)] @import.src)
146
158
  (call function: (identifier) @call.id)
147
159
  (call function: (attribute attribute: (identifier) @call.member))
148
160
  `;
149
161
  const PY_BUILTIN_METHODS = new Set([
150
- "get", "set", "keys", "values", "items", "pop", "popitem", "update", "setdefault", "copy", "clear",
151
- "append", "extend", "insert", "remove", "reverse", "sort", "count", "index",
152
- "add", "discard", "union", "intersection", "difference",
153
- "format", "join", "split", "rsplit", "splitlines", "strip", "lstrip", "rstrip",
154
- "startswith", "endswith", "replace", "find", "rfind", "lower", "upper", "title", "capitalize",
155
- "encode", "decode", "isdigit", "isalpha", "isalnum", "isspace",
156
- "read", "write", "close", "open", "readline", "readlines",
157
- "run", "wait", "poll", "communicate",
162
+ "get", "set", "keys", "values", "items", "pop", "popitem", "update", "setdefault", "copy",
163
+ "clear", "append", "extend", "insert", "remove", "reverse", "sort", "count", "index", "add",
164
+ "discard", "union", "intersection", "difference", "format", "join", "split", "rsplit",
165
+ "splitlines", "strip", "lstrip", "rstrip", "startswith", "endswith", "replace", "find",
166
+ "rfind", "lower", "upper", "title", "capitalize", "encode", "decode", "isdigit", "isalpha",
167
+ "isalnum", "isspace", "read", "write", "close", "open", "readline", "readlines", "run",
168
+ "wait", "poll", "communicate",
158
169
  ]);
159
170
  const PYTHON = {
160
171
  id: "python",
@@ -282,7 +293,9 @@ const PHP = {
282
293
  const YAML_QUERY = `
283
294
  (block_node (anchor (anchor_name) @anchor.name)) @anchor.def
284
295
  (flow_node (anchor (anchor_name) @anchor.name)) @anchor.def
296
+ ;; An alias is a reference to its anchor.
285
297
  (alias (alias_name) @call.id)
298
+ ;; The whole document gets a root symbol (named via fallbackDefName).
286
299
  (stream) @doc.def
287
300
  `;
288
301
  const YAML = {
@@ -291,42 +304,30 @@ const YAML = {
291
304
  grammarKey: "yaml",
292
305
  loadGrammar: () => loadNativeTreeSitter().yaml,
293
306
  query: YAML_QUERY,
294
- // Both block-style (`key: &x\n ...`) and flow-style (`key: &x {...}`) anchors
295
- // wrap only the sigil+name; ascendToDef climbs from anchor_name -> anchor ->
296
- // this enclosing node to find the def whose byte range covers the full value.
297
307
  defNodeTypes: new Set(["block_node", "flow_node"]),
298
308
  defKindOf: { "anchor.def": "variable", "doc.def": "file" },
299
309
  nameToDef: { "anchor.name": "anchor.def" },
300
310
  builtinMethods: new Set(),
301
- // Alias references aren't calls; label the edge accordingly (Task 5).
302
311
  referenceEdgeType: "references",
303
- // Most aliases reference an anchor from OUTSIDE that anchor's own byte range
304
- // (a sibling key, not a nested value) — without a whole-file fallback symbol,
305
- // attributeCalls's containment check would silently drop them. See Task 4.
312
+ // Most aliases sit outside their anchor's byte range, so without a root symbol
313
+ // call attribution would drop them. The file's own name is the root.
306
314
  fallbackDefName: (file) => basename(file),
307
- // Helm charts / templated CI configs are common .yaml content that only
308
- // becomes valid YAML after a render step (issue #33). The negative lookbehind
309
- // on "{{" excludes GitHub Actions' `${{ expression }}` syntax, which is
310
- // ordinary, always-valid YAML — every workflow file in this repo uses it, so
311
- // treating it as a templating marker would blanket-disable the fail-closed
312
- // gate for .github/workflows/**.
313
- templatingMarkers: [/(?<!\$)\{\{/, /\{%/],
315
+ // Go/Jinja/Helm delimiters make a file invalid YAML until rendered. A `{{` not
316
+ // preceded by `$` (GitHub Actions `${{ }}` is plain valid YAML) and a Jinja `{%`.
317
+ templatingMarkers: [/(?<!\$)\{\{/u, /\{%/u],
318
+ // `.tpl` files (Helm `_helpers.tpl`) are templates by convention, whatever they hold.
314
319
  alwaysTemplatedExtensions: [".tpl"],
315
320
  };
316
321
  export const LANGUAGES = [TYPESCRIPT, TSX, PYTHON, GO, PHP, YAML];
322
+ /** Every registered extension, de-duplicated, in registry order. */
317
323
  export const CODE_EXTENSIONS = [...new Set(LANGUAGES.flatMap((l) => l.extensions))];
324
+ /** First spec (registry order) with an extension the file name ends with. */
318
325
  export function languageFor(file) {
319
- for (const lang of LANGUAGES) {
320
- if (lang.extensions.some((ext) => file.endsWith(ext)))
321
- return lang;
322
- }
323
- return null;
326
+ return LANGUAGES.find((l) => l.extensions.some((ext) => file.endsWith(ext))) ?? null;
324
327
  }
325
- /** Prose formats worth drafting a decision from even though no LanguageSpec parses
326
- * them — no grammar, no symbols/edges, just eligible input to synthesis (issue #12). */
328
+ /** Prose formats eligible as synthesis input but never parsed. */
327
329
  export const PROSE_EXTENSIONS = [".md"];
328
- /** Broader than languageFor: "is this worth reasoning about" (synthesis input)
329
- * rather than "can tree-sitter parse this" (symbol/dependency extraction). */
330
+ /** True for parseable code or prose. Deliberately broader than parseability. */
330
331
  export function isSubstantive(file) {
331
332
  return languageFor(file) !== null || PROSE_EXTENSIONS.some((ext) => file.endsWith(ext));
332
333
  }
@@ -32,10 +32,10 @@ export declare class NativeTreeSitterLoadError extends Error {
32
32
  * a worker/IPC boundary keeps only its plain properties. The name check
33
33
  * survives both. */
34
34
  export declare function isParserLoadError(e: unknown): boolean;
35
- /** Load all native tree-sitter addons (the parser runtime + every grammar) from
35
+ /** Loads every native tree-sitter addon (the parser runtime and each grammar) from
36
36
  * content-addressed copies in a per-user temp cache — never from the installed
37
37
  * package. Windows keeps loaded `.node` files locked for the
38
- * process lifetime; redirecting the upstream loaders means npm can replace the
39
- * installed package during an active MCP session without killing that session
40
- * or falling back to a stale binary. */
38
+ * lifetime of the process, so pointing the upstream loaders at copies lets npm
39
+ * replace the installed package during a live MCP session without killing it and
40
+ * without ever loading a stale binary. */
41
41
  export declare function loadNativeTreeSitter(): NativeTreeSitterRuntime;
@@ -10,13 +10,14 @@ const COPY_PREFIX = "hunch-tree-sitter-";
10
10
  const CACHE_PREFIX = "hunch-tree-sitter-cache-";
11
11
  /** A hash dir no process has used for this long is pruned. */
12
12
  const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
13
+ /** npm packages whose native addons are loaded from isolated copies. */
13
14
  const NATIVE_PACKAGES = [
14
15
  "tree-sitter",
15
16
  "tree-sitter-typescript",
16
17
  "tree-sitter-python",
17
18
  "tree-sitter-go",
18
19
  "tree-sitter-php",
19
- "@tree-sitter-grammars/tree-sitter-yaml",
20
+ "tree-sitter-yaml",
20
21
  ];
21
22
  let runtime = null;
22
23
  /** The parser runtime itself is unavailable — the addons could not be copied or
@@ -87,8 +88,11 @@ function removeStaleCopies() {
87
88
  }
88
89
  }
89
90
  }
91
+ /** node-gyp-build keys the redirect on the package's own `name`, not the
92
+ * specifier it was required by — the two differ for an npm alias. */
90
93
  function environmentKey(packageName) {
91
- return `${packageName.toUpperCase().replaceAll("-", "_")}_PREBUILD`;
94
+ const { name } = runtimeRequire(`${packageName}/package.json`);
95
+ return `${name.toUpperCase().replaceAll("-", "_")}_PREBUILD`;
92
96
  }
93
97
  /** The per-user copy cache. A binary copied to a fresh path costs a first-load
94
98
  * assessment (macOS checks every never-seen dylib: ~2s per addon, six addons)
@@ -197,12 +201,12 @@ function pruneCache(root, keep) {
197
201
  catch { /* in use or already gone */ }
198
202
  }
199
203
  }
200
- /** Load all native tree-sitter addons (the parser runtime + every grammar) from
204
+ /** Loads every native tree-sitter addon (the parser runtime and each grammar) from
201
205
  * content-addressed copies in a per-user temp cache — never from the installed
202
206
  * package. Windows keeps loaded `.node` files locked for the
203
- * process lifetime; redirecting the upstream loaders means npm can replace the
204
- * installed package during an active MCP session without killing that session
205
- * or falling back to a stale binary. */
207
+ * lifetime of the process, so pointing the upstream loaders at copies lets npm
208
+ * replace the installed package during a live MCP session without killing it and
209
+ * without ever loading a stale binary. */
206
210
  export function loadNativeTreeSitter() {
207
211
  if (runtime)
208
212
  return runtime;
@@ -219,13 +223,12 @@ export function loadNativeTreeSitter() {
219
223
  }
220
224
  }
221
225
  function loadRuntime() {
222
- // Three binding spellings: prebuilds ship as tree-sitter[-typescript|-python].node
223
- // or, for a scoped package like @tree-sitter-grammars/tree-sitter-yaml, as
224
- // @scope+name.node; from-source builds are named after the binding.gyp target
225
- // with underscores (tree_sitter_runtime_binding.node, tree_sitter_python_binding.node,
226
- // tree_sitter_yaml_binding.node, …). Missing the underscore names let an
227
- // already-loaded source-built addon slip past this guard and defeat the
228
- // file-lock isolation entirely (issue #52).
226
+ // Recognised addon file names: the prebuilt names of the runtime and of each
227
+ // grammar package, the `@scope+name` spelling that scoped packages use inside
228
+ // their prebuilds, and from-source builds, which are named after the
229
+ // binding.gyp target with underscores and end in `_binding`. Missing the
230
+ // underscore form once let an already-loaded source build slip past isolation
231
+ // (issue #52).
229
232
  const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /(?:tree-sitter(?:-typescript|-python|-go|-php|-yaml)?|tree_sitter(?:_[a-z]+)*_binding)\.node$/.test(path)
230
233
  && !new RegExp(`(?:^|[\\\\/])(?:${COPY_PREFIX}\\d+-|${CACHE_PREFIX})`).test(path));
231
234
  if (preloaded.length) {
@@ -260,7 +263,7 @@ function loadRuntime() {
260
263
  const python = runtimeRequire("tree-sitter-python");
261
264
  const go = runtimeRequire("tree-sitter-go");
262
265
  const php = runtimeRequire("tree-sitter-php");
263
- const yaml = runtimeRequire("@tree-sitter-grammars/tree-sitter-yaml");
266
+ const yaml = runtimeRequire("tree-sitter-yaml");
264
267
  runtime = { Parser, typescript: languages.typescript, tsx: languages.tsx, python, go, php: php.php, yaml };
265
268
  }
266
269
  finally {
@@ -46,24 +46,18 @@ export interface ParsedFile {
46
46
  namespace: string | null;
47
47
  parseable: boolean;
48
48
  }
49
- /** Cap on a stored symbol's bodyText — large enough for review context, small
50
- * enough that a huge function/file doesn't bloat every JSON symbol record. */
49
+ /** Cap on stored symbol body text: enough context for review without bloating JSON records. */
51
50
  export declare const MAX_BODY_TEXT_CHARS = 4000;
52
51
  export declare function parseSource(file: string, source: string, opts?: {
53
52
  throwOnParseError?: boolean;
54
53
  }): ParsedFile | null;
55
54
  /** Map each call site to the innermost symbol whose byte-range contains it.
56
- * Keyed by the symbol's position (index) in `parsed.symbols` — NOT its
57
- * startByte, which is not a reliable per-symbol identity: a language whose
58
- * extractor merges a synthetic whole-file symbol with independently-derived
59
- * symbols (e.g. YAML's fallback-root synthetic symbol alongside Helm's
60
- * regex-derived `define` blocks) can produce two distinct symbols that both
61
- * start at byte 0. Indexing by array position is unique by construction,
62
- * regardless of byte overlap — the caller must consume the exact same
63
- * `parsed.symbols` array (or an equivalently-ordered copy) to look up a
64
- * symbol by the index this function returns. The value maps callee name ->
65
- * `memberOnly` (true iff every occurrence was a `x.foo()` member call, never
66
- * a direct `foo()`), so the indexer can resolve member calls conservatively. */
55
+ * The result is keyed by the symbol's INDEX in `parsed.symbols`, not its start
56
+ * byte: two distinct symbols can share a start byte (a synthetic whole-file root
57
+ * beside others starting at byte 0), so callers must look symbols up in the same
58
+ * array order. Each value maps callee name -> memberOnly, true iff every
59
+ * occurrence of that callee inside the symbol was a member call, so the indexer
60
+ * can resolve member calls conservatively. */
67
61
  export declare function attributeCalls(parsed: ParsedFile): Map<number, Map<string, boolean>>;
68
62
  /** Map each call bound to an imported module — `ns.fn()` through a module-object
69
63
  * binding, or `g()` through an aliased member import — to the innermost symbol
@@ -20,28 +20,30 @@ function bundleFor(spec) {
20
20
  }
21
21
  return b;
22
22
  }
23
+ /** Per-spec templating markers with g/y stripped, so `.test()` is stateless.
24
+ * Built once per language spec rather than on every parseSource call. */
25
+ const statelessMarkers = new WeakMap();
26
+ function templatingMarkersFor(spec) {
27
+ let markers = statelessMarkers.get(spec);
28
+ if (!markers) {
29
+ markers = (spec.templatingMarkers ?? []).map((re) => new RegExp(re.source, re.flags.replace(/[gy]/g, "")));
30
+ statelessMarkers.set(spec, markers);
31
+ }
32
+ return markers;
33
+ }
23
34
  const STR_QUOTES = /^['"`]|['"`]$/g;
24
- /** Cap on a stored symbol's bodyText — large enough for review context, small
25
- * enough that a huge function/file doesn't bloat every JSON symbol record. */
35
+ /** Cap on stored symbol body text: enough context for review without bloating JSON records. */
26
36
  export const MAX_BODY_TEXT_CHARS = 4000;
27
37
  export function parseSource(file, source, opts = {}) {
28
38
  const spec = languageFor(file);
29
39
  if (!spec)
30
40
  return null;
31
- // Templated text (Helm chart / Jinja CI config) isn't {spec.id} yet — a real
32
- // grammar correctly reports ERROR nodes for the delimiters. Still run the
33
- // parse below (a well-formed anchor elsewhere in the file still contributes
34
- // a real symbol, same as any other YAML file) — just don't let those
35
- // expected errors fail-close the whole-repo scan on content that was never
36
- // meant to stand alone (#33). Heavy top-level templating can break error
37
- // recovery badly enough that even the whole-file root node never forms
38
- // (root.type becomes "ERROR", not "stream") — the fallback-symbol synthesis
39
- // below covers that case so the file doesn't vanish from the component graph.
40
- // String.prototype.search ignores lastIndex (unlike RegExp.test with a /g or
41
- // /y flag), so a future templatingMarkers entry can't introduce cross-call
42
- // statefulness here even if it forgets to keep its pattern flag-free.
43
- const templated = (spec.alwaysTemplatedExtensions?.some((ext) => file.endsWith(ext)) ?? false)
44
- || (spec.templatingMarkers?.some((marker) => source.search(marker) !== -1) ?? false);
41
+ // Templated text is not valid language text until rendered. Still parse, so real
42
+ // anchors/symbols are extracted, but do not let the expected errors mark the file
43
+ // unparseable. Severe templating may stop even the root document node from
44
+ // forming; the fallback whole-file symbol below covers that case.
45
+ const templated = (spec.alwaysTemplatedExtensions ?? []).some((ext) => file.endsWith(ext))
46
+ || templatingMarkersFor(spec).some((re) => re.test(source));
45
47
  const { parser, query, bindingQuery } = bundleFor(spec);
46
48
  // The native binding caps its scratch buffer at 32 KB unless bufferSize is
47
49
  // given — without this, any source >= 32768 bytes throws "Invalid argument"
@@ -94,15 +96,12 @@ export function parseSource(file, source, opts = {}) {
94
96
  const cname = cap.name;
95
97
  const node = cap.node;
96
98
  if (cname.endsWith(".def")) {
97
- // Keep the FIRST classification a node id receives: a query may have
98
- // several patterns matching the same node at different specificity
99
- // (e.g. a Python method inside a class body matches both a class-nested
100
- // "method.def" pattern and a general "fn.def" pattern — Task 4 relies on
101
- // this to classify methods correctly without special-casing Python here).
99
+ // First classification wins: a node matched by several patterns keeps the
100
+ // kind it received first (Python nested methods, Go struct/interface specs).
102
101
  if (!pendingDefs.has(node.id))
103
102
  pendingDefs.set(node.id, { kind: spec.defKindOf[cname], def: node });
104
103
  }
105
- else if (spec.nameToDef[cname]) {
104
+ else if (Object.hasOwn(spec.nameToDef, cname)) {
106
105
  // name capture: find its parent def node id by walking up to the def type
107
106
  const defNode = ascendToDef(node, spec.defNodeTypes);
108
107
  if (defNode) {
@@ -168,29 +167,21 @@ export function parseSource(file, source, opts = {}) {
168
167
  }
169
168
  }
170
169
  for (const { kind, def, name } of pendingDefs.values()) {
171
- const resolvedName = name ?? spec.fallbackDefName?.(file);
172
- if (!resolvedName)
170
+ const symbolName = name ?? spec.fallbackDefName?.(file);
171
+ if (!symbolName)
173
172
  continue;
174
173
  const loc = def.endPosition.row - def.startPosition.row + 1;
175
174
  symbols.push({
176
- name: resolvedName, kind,
175
+ name: symbolName, kind,
177
176
  startByte: def.startIndex, endByte: def.endIndex, loc,
178
177
  bodyText: originalText(def).slice(0, MAX_BODY_TEXT_CHARS),
179
178
  });
180
179
  }
181
- // Every other successfully-parsed YAML file gets at least a file-root symbol
182
- // (fallbackDefName). If templating broke error recovery badly enough that
183
- // the doc.def capture never fired, synthesize the same fallback here rather
184
- // than let the file silently drop out of the component graph. Push it before
185
- // the sort below — parse()'s callers (indexer.ts) rely on symbols staying in
186
- // start-byte order.
180
+ // Severe templating can stop the root node from forming; synthesize the whole-file symbol.
187
181
  if (templated && spec.fallbackDefName && !symbols.some((s) => s.kind === "file")) {
188
182
  symbols.push({
189
- name: spec.fallbackDefName(file),
190
- kind: "file",
191
- startByte: 0,
192
- endByte: source.length,
193
- loc: source.split("\n").length,
183
+ name: spec.fallbackDefName(file), kind: "file",
184
+ startByte: 0, endByte: source.length, loc: source.split("\n").length,
194
185
  bodyText: source.slice(0, MAX_BODY_TEXT_CHARS),
195
186
  });
196
187
  }
@@ -241,7 +232,7 @@ function inToleratedScope(node, scopes) {
241
232
  }
242
233
  return false;
243
234
  }
244
- /** Walk up to the nearest node whose type is a definition this language recognizes. */
235
+ /** Nearest ancestor of `node` whose type is a definition type, or null. */
245
236
  function ascendToDef(node, defNodeTypes) {
246
237
  let cur = node.parent;
247
238
  while (cur) {
@@ -252,17 +243,12 @@ function ascendToDef(node, defNodeTypes) {
252
243
  return null;
253
244
  }
254
245
  /** Map each call site to the innermost symbol whose byte-range contains it.
255
- * Keyed by the symbol's position (index) in `parsed.symbols` — NOT its
256
- * startByte, which is not a reliable per-symbol identity: a language whose
257
- * extractor merges a synthetic whole-file symbol with independently-derived
258
- * symbols (e.g. YAML's fallback-root synthetic symbol alongside Helm's
259
- * regex-derived `define` blocks) can produce two distinct symbols that both
260
- * start at byte 0. Indexing by array position is unique by construction,
261
- * regardless of byte overlap — the caller must consume the exact same
262
- * `parsed.symbols` array (or an equivalently-ordered copy) to look up a
263
- * symbol by the index this function returns. The value maps callee name ->
264
- * `memberOnly` (true iff every occurrence was a `x.foo()` member call, never
265
- * a direct `foo()`), so the indexer can resolve member calls conservatively. */
246
+ * The result is keyed by the symbol's INDEX in `parsed.symbols`, not its start
247
+ * byte: two distinct symbols can share a start byte (a synthetic whole-file root
248
+ * beside others starting at byte 0), so callers must look symbols up in the same
249
+ * array order. Each value maps callee name -> memberOnly, true iff every
250
+ * occurrence of that callee inside the symbol was a member call, so the indexer
251
+ * can resolve member calls conservatively. */
266
252
  export function attributeCalls(parsed) {
267
253
  const out = new Map();
268
254
  for (const call of parsed.calls) {
@@ -278,9 +264,11 @@ export function attributeCalls(parsed) {
278
264
  }
279
265
  }
280
266
  if (best && best.name !== call.callee) {
281
- if (!out.has(bestIndex))
282
- out.set(bestIndex, new Map());
283
- const m = out.get(bestIndex);
267
+ let m = out.get(bestIndex);
268
+ if (!m) {
269
+ m = new Map();
270
+ out.set(bestIndex, m);
271
+ }
284
272
  const prev = m.get(call.callee);
285
273
  m.set(call.callee, prev === undefined ? call.member : prev && call.member);
286
274
  }
@@ -82,6 +82,7 @@ export function renderHunchSection(store, root) {
82
82
  bugs: store.json.loadAll("bugs").length,
83
83
  constraints: store.json.loadAll("constraints").length,
84
84
  components: store.json.loadAll("components").length,
85
+ // Public policies only: private-overlay policies must not change the committed block.
85
86
  policies: root ? new PolicyRepository(root, store).listPolicies({ publicOnly: true }).length : 0,
86
87
  findings: store.json.loadAll("findings").filter((f) => f.triage === "open" || f.triage === "accepted-risk" || f.triage === "scheduled").length,
87
88
  };
@@ -32,12 +32,10 @@ const ENTRIES = [
32
32
  ".hunch/local.json",
33
33
  // The strict hook's append-only catch-log: clone-local, never a memory record.
34
34
  ".hunch/events.log",
35
- // The post-merge hook's queue of detected-but-unconfirmed commit-provenance
36
- // repairs — clone-local scratch state, never committed (`hunch repair-provenance
37
- // --apply` confirms and clears it).
35
+ // The post-merge hook's clone-local queue of unconfirmed provenance repairs —
36
+ // never committed; cleared by `hunch repair-provenance --apply`.
38
37
  ".hunch/pending-commit-repairs.json",
39
- // Tombstones for commit-repair matches a human rejected via `--drop` — clone-local
40
- // scratch state, same discipline as the queue above.
38
+ // Tombstones for matches rejected via `--drop` — clone-local, never committed.
41
39
  ".hunch/dropped-commit-repairs.json",
42
40
  // A local PRIVATE overlay store (HUNCH_PRIVATE_DIR) for sensitive memory — never
43
41
  // committed. This is the conventional in-repo path; point the env elsewhere for a