@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 +2 -0
- package/dist/extractors/diff.js +19 -3
- package/dist/extractors/indexer.js +65 -6
- package/dist/extractors/languages.js +114 -0
- package/dist/extractors/nativeTreeSitter.js +9 -7
- package/dist/extractors/parse.js +25 -68
- package/dist/integrations/claudemd.js +2 -1
- package/dist/synthesis/synthesize.js +15 -4
- package/package.json +2 -1
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.
|
package/dist/extractors/diff.js
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
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) ??
|
|
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
|
-
|
|
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) =>
|
|
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 =
|
|
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,
|
|
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 (
|
|
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
|
|
60
|
-
* keeps loaded `.node` files locked for the
|
|
61
|
-
* upstream loaders means npm can replace the
|
|
62
|
-
* MCP session without killing that session
|
|
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
|
-
|
|
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 {
|
package/dist/extractors/parse.js
CHANGED
|
@@ -1,62 +1,25 @@
|
|
|
1
|
+
import { languageFor } from "./languages.js";
|
|
1
2
|
import { loadNativeTreeSitter } from "./nativeTreeSitter.js";
|
|
2
|
-
const { Parser
|
|
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(
|
|
35
|
-
let b = cache.get(
|
|
5
|
+
function bundleFor(spec) {
|
|
6
|
+
let b = cache.get(spec.grammarKey);
|
|
36
7
|
if (!b) {
|
|
37
8
|
const parser = new Parser();
|
|
38
|
-
|
|
39
|
-
|
|
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(
|
|
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
|
|
57
|
-
if (!
|
|
19
|
+
const spec = languageFor(file);
|
|
20
|
+
if (!spec)
|
|
58
21
|
return null;
|
|
59
|
-
const { parser, query } = bundleFor(
|
|
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
|
-
|
|
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:
|
|
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 (!
|
|
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
|
|
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 (
|
|
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("- `
|
|
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
|
-
|
|
9
|
-
|
|
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 (
|
|
51
|
+
if (isTrivialSubject(meta))
|
|
41
52
|
return { status: "skipped", reason: `trivial subject: ${meta.subject}` };
|
|
42
|
-
const codeFiles = meta.files.filter((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.
|
|
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
|
},
|