@davesheffer/hunch 1.8.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,6 +49,8 @@ Hunch creates a local graph of:
49
49
  - **Constraints** — the invariants a change must not violate.
50
50
  - **Bug lineage** — the root cause behind fixes, recurrences, and regression guards.
51
51
  - **Architecture** — symbols, components, dependencies, blast radius, and fragility.
52
+ Deep code-structure parsing covers **TypeScript, JavaScript, and Python** (via a language
53
+ registry — each new language is one entry); the "why" layer works for any language.
52
54
 
53
55
  It then puts that context where work happens: MCP tools, the CLI, a VS Code Change Gate, git hooks,
54
56
  and an optional pull-request guard.
@@ -16,12 +16,24 @@ const DECL_PATTERNS = [
16
16
  { kind: "type", re: /^\s*(?:export\s+)?type\s+([A-Za-z_$][\w$]*)\s*[=<]/ },
17
17
  { kind: "const", re: /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/ },
18
18
  { kind: "const", re: /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?function/ },
19
+ { kind: "function", re: /^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/ },
20
+ // No Python-specific class pattern needed: the generic TS `class` pattern above has no
21
+ // trailing-syntax requirement (no `{`/`:`), so it already matches Python's
22
+ // `class Foo(Bar):` header too, and — since declOf() returns on the first match —
23
+ // always wins for Python class lines before any Python-specific pattern would run.
19
24
  ];
25
+ import { languageFor } from "./languages.js";
20
26
  const IMPORT_RE = /^\s*import\s+(?:[^'"]*from\s+)?['"]([^'"]+)['"]/;
21
27
  const CONT_IMPORT_RE = /^\s*\}?\s*from\s+['"]([^'"]+)['"]/; // multi-line: "} from 'x'"
22
28
  const REQUIRE_RE = /\brequire\(\s*['"]([^'"]+)['"]\s*\)/;
23
- const CODE_EXT = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
24
- const isCode = (p) => !!p && CODE_EXT.test(p);
29
+ // "import os" / "import a.b.c" / "import os as o" / "import os, sys" / trailing "# comment".
30
+ // Anchored to the END of the line (optional "as alias", comma-separated modules, comment)
31
+ // so it matches a COMPLETE Python import statement only — this deliberately rejects
32
+ // TypeScript's `import Foo = Bar.Baz;` (import-equals), which would otherwise falsely
33
+ // look like a Python "import Foo" prefix match.
34
+ const PY_IMPORT_RE = /^\s*import\s+([A-Za-z_][\w.]*)(?:\s+as\s+\w+)?(?:\s*,\s*[A-Za-z_][\w.]*(?:\s+as\s+\w+)?)*\s*(?:#.*)?$/;
35
+ const PY_FROM_IMPORT_RE = /^\s*from\s+([.\w]+)\s+import\s+/; // "from os import path" / "from . import x"
36
+ const isCode = (p) => !!p && languageFor(p) !== null;
25
37
  function declOf(line) {
26
38
  for (const { kind, re } of DECL_PATTERNS) {
27
39
  const m = re.exec(line);
@@ -31,7 +43,11 @@ function declOf(line) {
31
43
  return null;
32
44
  }
33
45
  function importOf(line) {
34
- const m = IMPORT_RE.exec(line) ?? CONT_IMPORT_RE.exec(line) ?? REQUIRE_RE.exec(line);
46
+ const m = IMPORT_RE.exec(line) ??
47
+ CONT_IMPORT_RE.exec(line) ??
48
+ REQUIRE_RE.exec(line) ??
49
+ PY_FROM_IMPORT_RE.exec(line) ??
50
+ PY_IMPORT_RE.exec(line);
35
51
  return m ? m[1] : null;
36
52
  }
37
53
  function stripAB(p) {
@@ -8,14 +8,14 @@
8
8
  * then runs HunchStore.reindex() to refresh the SQLite index.
9
9
  */
10
10
  import { readFileSync, statSync, readdirSync } from "node:fs";
11
- import { join, relative, posix } from "node:path";
11
+ import { join, relative, dirname, posix } from "node:path";
12
12
  import { parseSource, attributeCalls } from "./parse.js";
13
13
  import { symbolId, componentId, edgeId, sha1 } from "../core/ids.js";
14
14
  import { externalImportNodeId, externalPackage } from "../core/externalImports.js";
15
15
  import { resolveRelativeImport } from "../core/relativeImports.js";
16
16
  import { extracted, inferred } from "../core/types.js";
17
17
  import { isGitRepo, trackedFiles, fileGitMetrics } from "./git.js";
18
- const CODE_EXTS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
18
+ import { CODE_EXTENSIONS, languageFor } from "./languages.js";
19
19
  const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".hunch", "coverage", ".next", "out"]);
20
20
  export function indexRepo(store, root, opts = {}) {
21
21
  const files = listCodeFiles(root);
@@ -84,9 +84,19 @@ export function indexRepo(store, root, opts = {}) {
84
84
  perFileImports.push({ file: rel, imports: parsed.imports });
85
85
  }
86
86
  const byId = new Map(symbols.map((s) => [s.id, s]));
87
+ // Language-aware import resolution, shared by the call-resolution "was this
88
+ // name actually imported?" gate (below) and the depends_on edge derivation
89
+ // (pass 3): a Python cross-file call/import must resolve through the same
90
+ // relative/absolute Python rules as everything else, not silently fail the
91
+ // JS/TS resolver and look unimported.
92
+ const hasSrcLayout = [...fileSymbols.keys()].some((f) => f.startsWith("src/"));
93
+ const pyRoots = hasSrcLayout ? ["", "src"] : [""];
94
+ const resolveImportTarget = (file, spec) => languageFor(file)?.id === "python"
95
+ ? resolvePythonImport(file, spec, fileSymbols, pyRoots)
96
+ : resolveImport(file, spec, fileSymbols);
87
97
  const importedFiles = new Map(perFileImports.map(({ file, imports }) => [
88
98
  file,
89
- new Set(imports.map((specifier) => resolveImport(file, specifier, fileSymbols)).filter((target) => !!target)),
99
+ new Set(imports.map((specifier) => resolveImportTarget(file, specifier)).filter((target) => !!target)),
90
100
  ]));
91
101
  // ---- pass 2: resolve calls -> symbol-level edges -------------------------
92
102
  const edges = [];
@@ -151,7 +161,7 @@ export function indexRepo(store, root, opts = {}) {
151
161
  if (!fromCmp)
152
162
  continue;
153
163
  for (const spec of imports) {
154
- const target = resolveImport(file, spec, fileSymbols);
164
+ const target = resolveImportTarget(file, spec);
155
165
  if (target) {
156
166
  const toCmp = fileToComponent.get(target);
157
167
  if (!toCmp || toCmp === fromCmp)
@@ -213,7 +223,7 @@ function listCodeFiles(root) {
213
223
  if (isGitRepo(root)) {
214
224
  // Apply SKIP_DIRS to the git-tracked list too: a repo that (accidentally)
215
225
  // tracks node_modules/ or dist/ must not flood the graph with vendored symbols.
216
- const tracked = trackedFiles(root, CODE_EXTS)
226
+ const tracked = trackedFiles(root, CODE_EXTENSIONS)
217
227
  .filter((f) => !f.split(/[\\/]/).some((seg) => SKIP_DIRS.has(seg)))
218
228
  .map((f) => join(root, f));
219
229
  if (tracked.length > 0)
@@ -228,7 +238,7 @@ function listCodeFiles(root) {
228
238
  const st = statSync(abs);
229
239
  if (st.isDirectory())
230
240
  walk(abs);
231
- else if (CODE_EXTS.some((e) => name.endsWith(e)))
241
+ else if (languageFor(name) !== null)
232
242
  out.push(abs);
233
243
  }
234
244
  };
@@ -255,6 +265,55 @@ function resolveName(name, file, importedFiles, nameIndex, byId) {
255
265
  function resolveImport(fromFile, spec, fileSymbols) {
256
266
  return resolveRelativeImport(fromFile, spec, fileSymbols.keys()).path;
257
267
  }
268
+ /** First of `${modulePath}.py` / `${modulePath}/__init__.py` that's a tracked file,
269
+ * or null — the shared "module file vs. package __init__" candidate check used by
270
+ * both resolvePythonImport branches below. */
271
+ function firstExistingPyModule(modulePath, fileSymbols) {
272
+ const candidates = [`${modulePath}.py`, `${modulePath}/__init__.py`];
273
+ for (const c of candidates)
274
+ if (fileSymbols.has(c))
275
+ return c;
276
+ return null;
277
+ }
278
+ /** Resolve a Python import specifier (relative or absolute) to a concrete tracked
279
+ * file path. Sibling to resolveImport() — Python's leading dot means "N levels up
280
+ * from the importing module's own directory," not "a relative file-path fragment"
281
+ * the way JS/TS's `./`/`../` does. Absolute imports are resolved best-effort
282
+ * against `pyRoots` (repo root, plus a top-level `src/` layout if one exists) —
283
+ * no sys.path/PYTHONPATH emulation. A module's own package directory is always
284
+ * its containing directory, so relative resolution needs no repo-wide
285
+ * package-root search — only dot-counting from `fromFile`'s own location. */
286
+ function resolvePythonImport(fromFile, spec, fileSymbols, pyRoots) {
287
+ if (!spec.startsWith(".")) {
288
+ const specPath = spec.split(".").join("/");
289
+ for (const root of pyRoots) {
290
+ const modulePath = root ? `${root}/${specPath}` : specPath;
291
+ const found = firstExistingPyModule(modulePath, fileSymbols);
292
+ if (found)
293
+ return found;
294
+ }
295
+ return null;
296
+ }
297
+ const level = spec.length - spec.replace(/^\.+/, "").length;
298
+ const tail = spec.slice(level);
299
+ const dir = toPosix(dirname(fromFile));
300
+ const segments = dir === "." ? [] : dir.split("/");
301
+ const pop = level - 1;
302
+ if (pop > segments.length)
303
+ return null; // import points above the repo root — don't guess
304
+ const baseSegments = pop > 0 ? segments.slice(0, segments.length - pop) : segments;
305
+ const baseDir = baseSegments.join("/");
306
+ if (!tail) {
307
+ // bare `.`/`..`/etc — `from . import x` only ever resolves to the package's
308
+ // own __init__.py (we track the module path, never the imported name itself,
309
+ // matching resolveImport()'s granularity for JS/TS named imports).
310
+ const initPy = baseDir ? `${baseDir}/__init__.py` : "__init__.py";
311
+ return fileSymbols.has(initPy) ? initPy : null;
312
+ }
313
+ const tailPath = tail.split(".").join("/");
314
+ const modulePath = baseDir ? `${baseDir}/${tailPath}` : tailPath;
315
+ return firstExistingPyModule(modulePath, fileSymbols);
316
+ }
258
317
  /** Derive components from the directory layout: the directory immediately under
259
318
  * `src/` (or the top-level dir) groups files into a module component. */
260
319
  function deriveComponents(symbols) {
@@ -0,0 +1,114 @@
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.
7
+ */
8
+ import { loadNativeTreeSitter } from "./nativeTreeSitter.js";
9
+ const TS_QUERY = `
10
+ (function_declaration name: (identifier) @fn.name) @fn.def
11
+ (generator_function_declaration name: (identifier) @fn.name) @fn.def
12
+ (method_definition name: (property_identifier) @method.name) @method.def
13
+ (class_declaration name: (type_identifier) @class.name) @class.def
14
+ (interface_declaration name: (type_identifier) @iface.name) @iface.def
15
+ (type_alias_declaration name: (type_identifier) @type.name) @type.def
16
+ (variable_declarator
17
+ name: (identifier) @arrow.name
18
+ value: [(arrow_function) (function_expression)]) @arrow.def
19
+ (import_statement source: (string) @import.src)
20
+ (call_expression function: (identifier) @call.id)
21
+ (call_expression function: (member_expression property: (property_identifier) @call.member))
22
+ `;
23
+ const TS_BUILTIN_METHODS = new Set([
24
+ "map", "filter", "forEach", "reduce", "find", "findIndex", "some", "every", "includes",
25
+ "push", "pop", "shift", "unshift", "slice", "splice", "concat", "join", "split", "flat", "flatMap",
26
+ "indexOf", "lastIndexOf", "keys", "values", "entries", "sort", "reverse", "fill", "at",
27
+ "get", "set", "has", "add", "delete", "clear",
28
+ "then", "catch", "finally", "all", "race", "resolve", "reject",
29
+ "toString", "valueOf", "toJSON", "hasOwnProperty",
30
+ "replace", "replaceAll", "trim", "trimStart", "trimEnd", "padStart", "padEnd", "startsWith", "endsWith",
31
+ "toLowerCase", "toUpperCase", "charAt", "charCodeAt", "substring", "substr", "repeat", "match", "matchAll",
32
+ "call", "apply", "bind", "test", "exec", "now", "parse", "stringify", "from", "of", "isArray", "assign",
33
+ "log", "error", "warn", "info", "debug",
34
+ ]);
35
+ const TS_SHARED = {
36
+ id: "typescript",
37
+ query: TS_QUERY,
38
+ defNodeTypes: new Set([
39
+ "function_declaration", "generator_function_declaration", "method_definition",
40
+ "class_declaration", "interface_declaration", "type_alias_declaration", "variable_declarator",
41
+ ]),
42
+ defKindOf: {
43
+ "fn.def": "function", "method.def": "method", "class.def": "class",
44
+ "iface.def": "interface", "type.def": "type", "arrow.def": "function",
45
+ },
46
+ nameToDef: {
47
+ "fn.name": "fn.def", "method.name": "method.def", "class.name": "class.def",
48
+ "iface.name": "iface.def", "type.name": "type.def", "arrow.name": "arrow.def",
49
+ },
50
+ builtinMethods: TS_BUILTIN_METHODS,
51
+ };
52
+ const TYPESCRIPT = {
53
+ ...TS_SHARED,
54
+ extensions: [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs"],
55
+ grammarKey: "ts",
56
+ loadGrammar: () => loadNativeTreeSitter().typescript,
57
+ };
58
+ /** .tsx/.jsx use the TSX grammar variant; everything else in the TS spec uses
59
+ * the plain typescript grammar. Both share the same query/def maps/builtins,
60
+ * so this is a second LanguageSpec entry with a distinct grammarKey/loadGrammar
61
+ * only — not a second `id` (languageFor callers only care about extension match). */
62
+ const TSX = {
63
+ ...TS_SHARED,
64
+ extensions: [".tsx", ".jsx"],
65
+ grammarKey: "tsx",
66
+ loadGrammar: () => loadNativeTreeSitter().tsx,
67
+ };
68
+ const PY_QUERY = `
69
+ (class_definition
70
+ name: (identifier) @class.name
71
+ body: (block
72
+ [
73
+ (function_definition name: (identifier) @method.name) @method.def
74
+ (decorated_definition definition: (function_definition name: (identifier) @method.name) @method.def)
75
+ ])) @class.def
76
+ (function_definition name: (identifier) @fn.name) @fn.def
77
+ (import_statement name: (dotted_name) @import.src)
78
+ (import_statement name: (aliased_import name: (dotted_name) @import.src))
79
+ (import_from_statement module_name: (dotted_name) @import.src)
80
+ (import_from_statement module_name: (relative_import) @import.src)
81
+ (call function: (identifier) @call.id)
82
+ (call function: (attribute attribute: (identifier) @call.member))
83
+ `;
84
+ const PY_BUILTIN_METHODS = new Set([
85
+ "get", "set", "keys", "values", "items", "pop", "popitem", "update", "setdefault", "copy", "clear",
86
+ "append", "extend", "insert", "remove", "reverse", "sort", "count", "index",
87
+ "add", "discard", "union", "intersection", "difference",
88
+ "format", "join", "split", "rsplit", "splitlines", "strip", "lstrip", "rstrip",
89
+ "startswith", "endswith", "replace", "find", "rfind", "lower", "upper", "title", "capitalize",
90
+ "encode", "decode", "isdigit", "isalpha", "isalnum", "isspace",
91
+ "read", "write", "close", "open", "readline", "readlines",
92
+ "run", "wait", "poll", "communicate",
93
+ ]);
94
+ const PYTHON = {
95
+ id: "python",
96
+ extensions: [".py", ".pyi"],
97
+ grammarKey: "python",
98
+ loadGrammar: () => loadNativeTreeSitter().python,
99
+ query: PY_QUERY,
100
+ defNodeTypes: new Set(["function_definition", "class_definition"]),
101
+ defKindOf: { "fn.def": "function", "method.def": "method", "class.def": "class" },
102
+ nameToDef: { "fn.name": "fn.def", "method.name": "method.def", "class.name": "class.def" },
103
+ builtinMethods: PY_BUILTIN_METHODS,
104
+ };
105
+ export const LANGUAGES = [TYPESCRIPT, TSX, PYTHON];
106
+ export const CODE_EXTENSIONS = [...new Set(LANGUAGES.flatMap((l) => l.extensions))];
107
+ export function languageFor(file) {
108
+ for (const lang of LANGUAGES) {
109
+ if (lang.extensions.some((ext) => file.endsWith(ext)))
110
+ return lang;
111
+ }
112
+ return null;
113
+ }
114
+ //# sourceMappingURL=languages.js.map
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { basename, dirname, join } from "node:path";
5
5
  const runtimeRequire = createRequire(import.meta.url);
6
6
  const COPY_PREFIX = "hunch-tree-sitter-";
7
- const NATIVE_PACKAGES = ["tree-sitter", "tree-sitter-typescript"];
7
+ const NATIVE_PACKAGES = ["tree-sitter", "tree-sitter-typescript", "tree-sitter-python"];
8
8
  let runtime = null;
9
9
  function processIsAlive(pid) {
10
10
  if (pid === process.pid)
@@ -56,14 +56,15 @@ function copyNativeBinding(packageName, copyRoot, nodeGypBuild) {
56
56
  copyFileSync(source, destination);
57
57
  return packageCopy;
58
58
  }
59
- /** Load both native tree-sitter addons from process-owned temp copies. Windows
60
- * keeps loaded `.node` files locked for the process lifetime; redirecting the
61
- * upstream loaders means npm can replace the installed package during an active
62
- * MCP session without killing that session or falling back to a stale binary. */
59
+ /** Load all native tree-sitter addons (the parser runtime + every grammar) from
60
+ * process-owned temp copies. Windows keeps loaded `.node` files locked for the
61
+ * process lifetime; redirecting the upstream loaders means npm can replace the
62
+ * installed package during an active MCP session without killing that session
63
+ * or falling back to a stale binary. */
63
64
  export function loadNativeTreeSitter() {
64
65
  if (runtime)
65
66
  return runtime;
66
- const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /tree-sitter(?:-typescript)?\.node$/.test(path)
67
+ const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /tree-sitter(?:-typescript|-python)?\.node$/.test(path)
67
68
  && !new RegExp(`(?:^|[\\\\/])${COPY_PREFIX}\\d+-`).test(path));
68
69
  if (preloaded.length) {
69
70
  throw new Error(`tree-sitter native addon was loaded before Hunch could isolate it: ${preloaded.join(", ")}`);
@@ -80,7 +81,8 @@ export function loadNativeTreeSitter() {
80
81
  }
81
82
  const Parser = runtimeRequire("tree-sitter");
82
83
  const languages = runtimeRequire("tree-sitter-typescript");
83
- runtime = { Parser, typescript: languages.typescript, tsx: languages.tsx };
84
+ const python = runtimeRequire("tree-sitter-python");
85
+ runtime = { Parser, typescript: languages.typescript, tsx: languages.tsx, python };
84
86
  }
85
87
  catch (error) {
86
88
  try {
@@ -1,62 +1,25 @@
1
+ import { languageFor } from "./languages.js";
1
2
  import { loadNativeTreeSitter } from "./nativeTreeSitter.js";
2
- const { Parser, typescript, tsx } = loadNativeTreeSitter();
3
- /** Extremely common builtin/array/object/string/promise method names. Member
4
- * calls to these (e.g. `arr.map(...)`) must NOT create call edges to unrelated
5
- * repo symbols that happen to share the name (DESIGN: keep the graph clean). */
6
- const BUILTIN_METHODS = new Set([
7
- "map", "filter", "forEach", "reduce", "find", "findIndex", "some", "every", "includes",
8
- "push", "pop", "shift", "unshift", "slice", "splice", "concat", "join", "split", "flat", "flatMap",
9
- "indexOf", "lastIndexOf", "keys", "values", "entries", "sort", "reverse", "fill", "at",
10
- "get", "set", "has", "add", "delete", "clear",
11
- "then", "catch", "finally", "all", "race", "resolve", "reject",
12
- "toString", "valueOf", "toJSON", "hasOwnProperty",
13
- "replace", "replaceAll", "trim", "trimStart", "trimEnd", "padStart", "padEnd", "startsWith", "endsWith",
14
- "toLowerCase", "toUpperCase", "charAt", "charCodeAt", "substring", "substr", "repeat", "match", "matchAll",
15
- "call", "apply", "bind", "test", "exec", "now", "parse", "stringify", "from", "of", "isArray", "assign",
16
- "log", "error", "warn", "info", "debug",
17
- ]);
18
- /** Tree-sitter query capturing every construct we care about in one pass. */
19
- const QUERY_SRC = `
20
- (function_declaration name: (identifier) @fn.name) @fn.def
21
- (generator_function_declaration name: (identifier) @fn.name) @fn.def
22
- (method_definition name: (property_identifier) @method.name) @method.def
23
- (class_declaration name: (type_identifier) @class.name) @class.def
24
- (interface_declaration name: (type_identifier) @iface.name) @iface.def
25
- (type_alias_declaration name: (type_identifier) @type.name) @type.def
26
- (variable_declarator
27
- name: (identifier) @arrow.name
28
- value: [(arrow_function) (function_expression)]) @arrow.def
29
- (import_statement source: (string) @import.src)
30
- (call_expression function: (identifier) @call.id)
31
- (call_expression function: (member_expression property: (property_identifier) @call.member))
32
- `;
3
+ const { Parser } = loadNativeTreeSitter();
33
4
  const cache = new Map();
34
- function bundleFor(lang, key) {
35
- let b = cache.get(key);
5
+ function bundleFor(spec) {
6
+ let b = cache.get(spec.grammarKey);
36
7
  if (!b) {
37
8
  const parser = new Parser();
38
- parser.setLanguage(lang);
39
- const query = new Parser.Query(lang, QUERY_SRC);
9
+ const grammar = spec.loadGrammar();
10
+ parser.setLanguage(grammar);
11
+ const query = new Parser.Query(grammar, spec.query);
40
12
  b = { parser, query };
41
- cache.set(key, b);
13
+ cache.set(spec.grammarKey, b);
42
14
  }
43
15
  return b;
44
16
  }
45
- function pickLanguage(file) {
46
- if (file.endsWith(".tsx") || file.endsWith(".jsx"))
47
- return { lang: tsx, key: "tsx" };
48
- if (file.endsWith(".ts") || file.endsWith(".mts") || file.endsWith(".cts"))
49
- return { lang: typescript, key: "ts" };
50
- if (file.endsWith(".js") || file.endsWith(".mjs") || file.endsWith(".cjs"))
51
- return { lang: typescript, key: "ts" };
52
- return null;
53
- }
54
17
  const STR_QUOTES = /^['"`]|['"`]$/g;
55
18
  export function parseSource(file, source) {
56
- const picked = pickLanguage(file);
57
- if (!picked)
19
+ const spec = languageFor(file);
20
+ if (!spec)
58
21
  return null;
59
- const { parser, query } = bundleFor(picked.lang, picked.key);
22
+ const { parser, query } = bundleFor(spec);
60
23
  // The native binding caps its scratch buffer at 32 KB unless bufferSize is
61
24
  // given — without this, any source >= 32768 bytes throws "Invalid argument"
62
25
  // and would abort the whole index run. Guard with try/catch as a backstop.
@@ -73,29 +36,27 @@ export function parseSource(file, source) {
73
36
  // group captures by their enclosing @*.def via a quick pass: we record names
74
37
  // keyed by the def node, then emit a symbol per def.
75
38
  const pendingDefs = new Map();
76
- const defKind = {
77
- "fn.def": "function", "method.def": "method", "class.def": "class",
78
- "iface.def": "interface", "type.def": "type", "arrow.def": "function",
79
- };
80
- const nameToDef = {
81
- "fn.name": "fn.def", "method.name": "method.def", "class.name": "class.def",
82
- "iface.name": "iface.def", "type.name": "type.def", "arrow.name": "arrow.def",
83
- };
84
39
  for (const cap of query.captures(tree.rootNode)) {
85
40
  const cname = cap.name;
86
41
  const node = cap.node;
87
42
  if (cname.endsWith(".def")) {
88
- pendingDefs.set(node.id, { kind: defKind[cname], def: node });
43
+ // Keep the FIRST classification a node id receives: a query may have
44
+ // several patterns matching the same node at different specificity
45
+ // (e.g. a Python method inside a class body matches both a class-nested
46
+ // "method.def" pattern and a general "fn.def" pattern — Task 4 relies on
47
+ // this to classify methods correctly without special-casing Python here).
48
+ if (!pendingDefs.has(node.id))
49
+ pendingDefs.set(node.id, { kind: spec.defKindOf[cname], def: node });
89
50
  }
90
- else if (nameToDef[cname]) {
51
+ else if (spec.nameToDef[cname]) {
91
52
  // name capture: find its parent def node id by walking up to the def type
92
- const defNode = ascendToDef(node);
53
+ const defNode = ascendToDef(node, spec.defNodeTypes);
93
54
  if (defNode) {
94
55
  const existing = pendingDefs.get(defNode.id);
95
56
  if (existing)
96
57
  existing.name = node.text;
97
58
  else
98
- pendingDefs.set(defNode.id, { kind: defKind[nameToDef[cname]], def: defNode, name: node.text });
59
+ pendingDefs.set(defNode.id, { kind: spec.defKindOf[spec.nameToDef[cname]], def: defNode, name: node.text });
99
60
  }
100
61
  }
101
62
  else if (cname === "import.src") {
@@ -106,7 +67,7 @@ export function parseSource(file, source) {
106
67
  }
107
68
  else if (cname === "call.member") {
108
69
  // skip builtin method names to avoid false edges to similarly-named symbols
109
- if (!BUILTIN_METHODS.has(node.text))
70
+ if (!spec.builtinMethods.has(node.text))
110
71
  calls.push({ callee: node.text, atByte: node.startIndex, endByte: node.endIndex, member: true });
111
72
  }
112
73
  }
@@ -123,15 +84,11 @@ export function parseSource(file, source) {
123
84
  symbols.sort((a, b) => a.startByte - b.startByte);
124
85
  return { symbols, imports, calls, parseable: !tree.rootNode.hasError };
125
86
  }
126
- /** Walk up to the nearest node whose type is a definition we recognize. */
127
- function ascendToDef(node) {
128
- const defTypes = new Set([
129
- "function_declaration", "generator_function_declaration", "method_definition",
130
- "class_declaration", "interface_declaration", "type_alias_declaration", "variable_declarator",
131
- ]);
87
+ /** Walk up to the nearest node whose type is a definition this language recognizes. */
88
+ function ascendToDef(node, defNodeTypes) {
132
89
  let cur = node.parent;
133
90
  while (cur) {
134
- if (defTypes.has(cur.type))
91
+ if (defNodeTypes.has(cur.type))
135
92
  return cur;
136
93
  cur = cur.parent;
137
94
  }
@@ -35,7 +35,8 @@ export function renderHunchSection(store, root) {
35
35
  lines.push("- `hunch_context(target_or_task)` — the minimal relevant slice for what you're about to do; a task phrase falls back to the closest graph matches. **Call FIRST.**");
36
36
  lines.push("- `hunch_structure(target?)` — the indexed shape of the repo/dir/file/symbol — orient from the graph, not grep rounds.");
37
37
  lines.push("- `hunch_runbook(task)` — the proven steps for a recurring task, before re-deriving them.");
38
- lines.push("- `hunch now` (CLI) — recent decisions + the live roadmap.");
38
+ lines.push("- `hunch_escalations()`the decisions only the HUMAN can make (topic conflicts, candidate/proposed rules, repaired rules needing a re-prove). Normally empty; when it isn't, ASK the user inline — an entry is a question, never an approval.");
39
+ lines.push("- `hunch now` (CLI) — recent decisions + the live roadmap; `hunch log` — the memory-move timeline (every capture/adopt/supersede/prune/repair, each revertable).");
39
40
  lines.push("");
40
41
  lines.push("**Before designing / choosing an approach:**");
41
42
  lines.push("- `hunch_why(target)` — why a file/symbol is shaped this way (decisions, bugs, constraints) — including what was already REJECTED.");
@@ -5,12 +5,23 @@ import { decisionId, bugId, constraintId } from "../core/ids.js";
5
5
  import { commitCoveredBy } from "../core/dupdetect.js";
6
6
  import { pathMatchesGlob } from "../core/glob.js";
7
7
  import { draftTripwires, knownRepoDeps } from "./tripwires.js";
8
- const CODE_RE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
9
- const SKIP_SUBJECT = /^(merge|revert|bump|chore\(deps\)|format|lint|wip)\b/i;
8
+ import { languageFor } from "../extractors/languages.js";
9
+ // "chore(deps):" is anchored separately (not via \b) because \b requires a
10
+ // word/non-word transition, and the character after the closing ")" is ":" or a
11
+ // space — both non-word — so no boundary ever fires there.
12
+ const SKIP_SUBJECT = /^(merge|revert|bump|format|lint|wip)\b|^chore\(deps\):/i;
10
13
  // Below this many changed code lines, a commit with no structural change and no
11
14
  // explanatory body isn't worth a paid LLM call. Tunable via HUNCH_SIG_MIN_LINES.
12
15
  const SIG_MIN_LINES = Number(process.env.HUNCH_SIG_MIN_LINES) || 12;
13
16
  const SIG_MIN_BODY = 40;
17
+ /** Trivial-subject commits (merge/revert/bump/format/...) are noise UNLESS the body
18
+ * carries real content — a squash/PR description often lands there, not on the
19
+ * subject. Gated on body length ALONE (not the full isSignificant() heuristic): a
20
+ * large auto-generated reformat or dependency-bump diff with no narrative must stay
21
+ * skipped even though it would trip isSignificant()'s line/file/structural checks. */
22
+ export function isTrivialSubject(meta) {
23
+ return SKIP_SUBJECT.test(meta.subject) && meta.body.trim().length < SIG_MIN_BODY;
24
+ }
14
25
  /** Is a commit substantive enough to spend a paid LLM synthesis call on? Pure and
15
26
  * deterministic. Any structural change (symbol/dependency delta), non-trivial
16
27
  * churn, several files, OR an explanatory commit body signals a real decision
@@ -37,9 +48,9 @@ export async function syncCommit(store, root, sha, opts = {}) {
37
48
  const meta = commitMeta(target, root);
38
49
  if (!meta)
39
50
  return { status: "skipped", reason: "commit not found" };
40
- if (SKIP_SUBJECT.test(meta.subject))
51
+ if (isTrivialSubject(meta))
41
52
  return { status: "skipped", reason: `trivial subject: ${meta.subject}` };
42
- const codeFiles = meta.files.filter((f) => CODE_RE.test(f));
53
+ const codeFiles = meta.files.filter((f) => languageFor(f) !== null);
43
54
  if (codeFiles.length === 0)
44
55
  return { status: "skipped", reason: "no code files changed" };
45
56
  // Seed the id from the COMMIT (stable across runs), not the LLM-generated title
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "license": "Apache-2.0",
5
5
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
6
  "description": "Architectural Conformance for AI-generated code: a git-native graph that deterministically blocks AI changes which break your architecture — the semantic invariants (layering, must-reach, dependency direction) pattern-SAST can't express — grounded in the decisions and bugs behind each rule, across any MCP assistant (Claude Code, Cursor, Copilot, Windsurf, Antigravity, Codex).",
@@ -58,6 +58,7 @@
58
58
  "@modelcontextprotocol/sdk": "^1.29.0",
59
59
  "commander": "^15.0.0",
60
60
  "tree-sitter": "0.21.1",
61
+ "tree-sitter-python": "^0.23.2",
61
62
  "tree-sitter-typescript": "^0.23.2",
62
63
  "zod": "^4.4.3"
63
64
  },