@davesheffer/hunch 0.1.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.
@@ -0,0 +1,136 @@
1
+ /** Deterministic git introspection for the extractor + learning loop.
2
+ * No LLM here — just parsing what git already knows. */
3
+ import { execFileSync } from "node:child_process";
4
+ function git(args, cwd) {
5
+ // stdio: capture stdout, silence stderr (so "no commits yet" etc. don't leak).
6
+ return execFileSync("git", args, {
7
+ cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024,
8
+ stdio: ["ignore", "pipe", "ignore"],
9
+ }).trim();
10
+ }
11
+ function gitSafe(args, cwd) {
12
+ try {
13
+ return git(args, cwd);
14
+ }
15
+ catch {
16
+ return "";
17
+ }
18
+ }
19
+ export function isGitRepo(cwd) {
20
+ return gitSafe(["rev-parse", "--is-inside-work-tree"], cwd) === "true";
21
+ }
22
+ export function headSha(cwd) {
23
+ return gitSafe(["rev-parse", "HEAD"], cwd);
24
+ }
25
+ /** Resolve any commit-ish (short sha / HEAD / branch) to a canonical full sha.
26
+ * Returns the input unchanged if it can't be resolved (e.g. not a git repo). */
27
+ export function revParse(ref, cwd) {
28
+ const r = ref.trim();
29
+ return gitSafe(["rev-parse", "--verify", "--quiet", r], cwd) || r;
30
+ }
31
+ /** Path to the hooks dir (honors core.hooksPath / worktrees). */
32
+ export function hooksDir(cwd) {
33
+ const p = gitSafe(["rev-parse", "--git-path", "hooks"], cwd);
34
+ return p || ".git/hooks";
35
+ }
36
+ export function gitDir(cwd) {
37
+ return gitSafe(["rev-parse", "--git-dir"], cwd) || ".git";
38
+ }
39
+ /** Files changed in a single commit. `--root` makes the initial commit (which
40
+ * has no parent) report its files as additions instead of returning nothing. */
41
+ export function commitFiles(sha, cwd) {
42
+ const out = gitSafe(["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha], cwd);
43
+ return out ? out.split("\n").filter(Boolean) : [];
44
+ }
45
+ /** Full metadata + changed files for a commit. */
46
+ export function commitMeta(sha, cwd) {
47
+ const raw = gitSafe(["show", "-s", "--format=%H%x1f%h%x1f%s%x1f%b%x1f%an%x1f%aI", sha], cwd);
48
+ if (!raw)
49
+ return null;
50
+ const [full = "", short = "", subject = "", body = "", author = "", date = ""] = raw.split("\x1f");
51
+ return { sha: full, shortSha: short, subject, body, author, date, files: commitFiles(sha, cwd) };
52
+ }
53
+ /** Machine-generated paths that carry no design "why" — lockfiles, build output,
54
+ * vendored deps, snapshots, source maps. Excluded from synthesis diffs via git
55
+ * pathspec BEFORE git assembles/orders the patch: a huge lockfile sorts ahead of
56
+ * src/ alphabetically and would otherwise eat the byte budget and truncate the
57
+ * real code change away. Exclude-only pathspecs are valid; `**` (glob magic)
58
+ * matches across directories AND at the repo root. (`*.lock` covers yarn / cargo
59
+ * / poetry / composer / Gemfile lockfiles.) */
60
+ const DIFF_NOISE = [
61
+ ":(exclude,glob)**/package-lock.json",
62
+ ":(exclude,glob)**/npm-shrinkwrap.json",
63
+ ":(exclude,glob)**/pnpm-lock.yaml",
64
+ ":(exclude,glob)**/go.sum",
65
+ ":(exclude,glob)**/*.lock",
66
+ ":(exclude,glob)**/dist/**",
67
+ ":(exclude,glob)**/build/**",
68
+ ":(exclude,glob)**/out/**",
69
+ ":(exclude,glob)**/coverage/**",
70
+ ":(exclude,glob)**/.next/**",
71
+ ":(exclude,glob)**/node_modules/**",
72
+ ":(exclude,glob)**/vendor/**",
73
+ // the Hunch's OWN machine-generated records — re-synthesizing a commit that
74
+ // wrote them would be circular noise, and they're large (JSON per record).
75
+ ":(exclude,glob)**/.hunch/**",
76
+ ":(exclude,glob)**/*.min.js",
77
+ ":(exclude,glob)**/*.map",
78
+ ":(exclude,glob)**/*.snap",
79
+ ":(exclude,glob)**/__snapshots__/**",
80
+ ":(exclude,glob)**/*.generated.*",
81
+ ];
82
+ /** The unified diff for a commit, truncated to keep synthesis prompts bounded.
83
+ * Machine-generated noise (see DIFF_NOISE) is excluded so the model spends its
84
+ * budget on code that encodes intent, not on regenerated lockfiles/build output. */
85
+ export function commitDiff(sha, cwd, maxBytes = 60_000) {
86
+ const out = gitSafe(["show", sha, "--no-color", "--format=", "--unified=2", "--", ...DIFF_NOISE], cwd);
87
+ return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
88
+ }
89
+ /** Number of commits touching a file in the last `days` (churn). */
90
+ export function fileChurn(file, cwd, days = 90) {
91
+ const out = gitSafe(["log", `--since=${days}.days.ago`, "--oneline", "--", file], cwd);
92
+ return out ? out.split("\n").filter(Boolean).length : 0;
93
+ }
94
+ /** The most recent commit short-sha that touched a file. */
95
+ export function lastCommitForFile(file, cwd) {
96
+ const sha = gitSafe(["log", "-1", "--format=%h", "--", file], cwd);
97
+ return sha ? `commit:${sha}` : "";
98
+ }
99
+ /** ISO author-date of the most recent commit touching a file ("" if none). */
100
+ export function lastChangeDate(file, cwd) {
101
+ return gitSafe(["log", "-1", "--format=%aI", "--", file], cwd);
102
+ }
103
+ /** Files staged for commit (for `hunch check` pre-commit enforcement). */
104
+ export function stagedFiles(cwd) {
105
+ const out = gitSafe(["diff", "--cached", "--name-only", "--diff-filter=ACMR"], cwd);
106
+ return out ? out.split("\n").filter(Boolean) : [];
107
+ }
108
+ /** Translate a backfill window spec into git-log window args.
109
+ * "90d" / bare "90" -> last 90 days | "40c" -> last 40 commits
110
+ * anything else -> passed to --since as an approxidate/date string. */
111
+ function windowArgs(spec, max) {
112
+ if (/^\d+c$/i.test(spec))
113
+ return ["-n", spec.replace(/c$/i, "")];
114
+ if (/^\d+d$/i.test(spec))
115
+ return [`--since=${spec.replace(/d$/i, "")} days ago`, "-n", String(max)];
116
+ if (/^\d+$/.test(spec))
117
+ return [`--since=${spec} days ago`, "-n", String(max)];
118
+ return [`--since=${spec}`, "-n", String(max)];
119
+ }
120
+ /** Recent commits (newest-first) for backfill. */
121
+ export function logSince(spec, cwd, max = 200) {
122
+ const out = gitSafe(["log", ...windowArgs(spec, max), "--format=%H"], cwd);
123
+ return out ? out.split("\n").filter(Boolean) : [];
124
+ }
125
+ /** Commits that look like bug fixes (for backfill bug seeding). */
126
+ export function fixCommits(spec, cwd, max = 200) {
127
+ const out = gitSafe(["log", ...windowArgs(spec, max), "--format=%H", "--grep=fix", "--grep=bug", "--grep=hotfix", "-i"], cwd);
128
+ return out ? out.split("\n").filter(Boolean) : [];
129
+ }
130
+ /** All tracked files matching the given extensions. */
131
+ export function trackedFiles(cwd, exts) {
132
+ const out = gitSafe(["ls-files"], cwd);
133
+ const all = out ? out.split("\n").filter(Boolean) : [];
134
+ return all.filter((f) => exts.some((e) => f.endsWith(e)));
135
+ }
136
+ //# sourceMappingURL=git.js.map
@@ -0,0 +1,271 @@
1
+ /**
2
+ * The indexer (DESIGN.md §4 "File changes" row, and `hunch index`).
3
+ * Deterministic, no LLM: walk the repo, parse every TS/JS file into symbols,
4
+ * resolve a best-effort call graph + import dependency graph, derive components
5
+ * from the directory layout, and compute churn / fan-in / fan-out metrics.
6
+ *
7
+ * Writes Symbol/Edge/Component records to the JSON source of truth. The caller
8
+ * then runs HunchStore.reindex() to refresh the SQLite index.
9
+ */
10
+ import { readFileSync, statSync, readdirSync } from "node:fs";
11
+ import { join, relative, dirname, posix } from "node:path";
12
+ import { parseSource, attributeCalls } from "./parse.js";
13
+ import { symbolId, componentId, edgeId, sha1 } from "../core/ids.js";
14
+ import { extracted, inferred } from "../core/types.js";
15
+ import { isGitRepo, trackedFiles, fileChurn, lastCommitForFile } from "./git.js";
16
+ const CODE_EXTS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
17
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".hunch", "coverage", ".next", "out"]);
18
+ export function indexRepo(store, root, opts = {}) {
19
+ const files = listCodeFiles(root);
20
+ const useGit = isGitRepo(root);
21
+ // ---- pass 1: parse files -> symbols, remember per-file calls & imports ----
22
+ const symbols = [];
23
+ const nameIndex = new Map(); // symbol name -> [symbol ids]
24
+ const fileSymbols = new Map(); // file -> symbol ids (in-file resolution)
25
+ const fileStartByteId = new Map(); // file -> (symbol startByte -> id)
26
+ const perFileCalls = [];
27
+ const perFileImports = [];
28
+ const churnCache = new Map();
29
+ let skipped = 0;
30
+ for (const abs of files) {
31
+ const rel = toPosix(relative(root, abs));
32
+ let src;
33
+ try {
34
+ src = readFileSync(abs, "utf8");
35
+ }
36
+ catch {
37
+ skipped++;
38
+ continue;
39
+ }
40
+ // one bad/oversized file must never abort the whole index run
41
+ let parsed;
42
+ try {
43
+ parsed = parseSource(rel, src);
44
+ }
45
+ catch {
46
+ skipped++;
47
+ continue;
48
+ }
49
+ if (!parsed) {
50
+ skipped++;
51
+ continue;
52
+ }
53
+ const churn = opts.churn !== false && useGit ? (churnCache.get(rel) ?? fileChurn(rel, root)) : 0;
54
+ churnCache.set(rel, churn);
55
+ const last = useGit ? lastCommitForFile(rel, root) : "";
56
+ const idsInFile = [];
57
+ const startByteId = new Map();
58
+ const idCounts = new Map(); // disambiguate same (file,name,kind)
59
+ for (const ps of parsed.symbols) {
60
+ const base = symbolId(rel, ps.name, ps.kind);
61
+ const n = idCounts.get(base) ?? 0;
62
+ idCounts.set(base, n + 1);
63
+ // parse() returns symbols sorted by start byte, so the ordinal is stable
64
+ const id = n === 0 ? base : `${base}_${n}`;
65
+ idsInFile.push(id);
66
+ startByteId.set(ps.startByte, id);
67
+ (nameIndex.get(ps.name) ?? nameIndex.set(ps.name, []).get(ps.name)).push(id);
68
+ symbols.push({
69
+ id, file: rel, name: ps.name, kind: ps.kind,
70
+ signature_hash: sha1(ps.bodyText).slice(0, 16),
71
+ calls: [], called_by: [],
72
+ metrics: { loc: ps.loc, churn_90d: churn, bug_count: 0, fan_in: 0, fan_out: 0 },
73
+ last_changed: last,
74
+ });
75
+ }
76
+ fileSymbols.set(rel, idsInFile);
77
+ fileStartByteId.set(rel, startByteId);
78
+ perFileCalls.push({ file: rel, bySym: attributeCalls(parsed) });
79
+ perFileImports.push({ file: rel, imports: parsed.imports });
80
+ }
81
+ const byId = new Map(symbols.map((s) => [s.id, s]));
82
+ // ---- pass 2: resolve calls -> symbol-level edges -------------------------
83
+ const edges = [];
84
+ const edgeSeen = new Set();
85
+ const addEdge = (e) => {
86
+ if (edgeSeen.has(e.id))
87
+ return;
88
+ edgeSeen.add(e.id);
89
+ edges.push(e);
90
+ };
91
+ for (const { file, bySym } of perFileCalls) {
92
+ const sbToId = fileStartByteId.get(file) ?? new Map();
93
+ for (const [callerStartByte, callees] of bySym) {
94
+ // resolve caller by its stable byte-offset identity (not name)
95
+ const callerId = sbToId.get(callerStartByte);
96
+ if (!callerId)
97
+ continue;
98
+ const callerName = byId.get(callerId)?.name ?? "?";
99
+ for (const [calleeName, memberOnly] of callees) {
100
+ const calleeId = resolveName(calleeName, file, nameIndex, byId);
101
+ if (!calleeId || calleeId === callerId)
102
+ continue;
103
+ // A member call `x.foo()` only yields an edge when `foo` resolves to a
104
+ // method or a same-file symbol — not a coincidentally-named top-level fn.
105
+ if (memberOnly) {
106
+ const sym = byId.get(calleeId);
107
+ if (!sym || (sym.kind !== "method" && sym.file !== file))
108
+ continue;
109
+ }
110
+ addEdge({
111
+ id: edgeId(callerId, calleeId, "calls"),
112
+ from: callerId, to: calleeId, type: "calls",
113
+ reason: `${callerName} calls ${calleeName}`, strength: 0.8,
114
+ provenance: extracted(0.8, [file]),
115
+ });
116
+ }
117
+ }
118
+ }
119
+ // fan-in / fan-out from resolved call edges
120
+ for (const e of edges) {
121
+ if (e.type !== "calls")
122
+ continue;
123
+ const from = byId.get(e.from);
124
+ const to = byId.get(e.to);
125
+ if (from) {
126
+ from.metrics.fan_out++;
127
+ from.calls.push(e.to);
128
+ }
129
+ if (to) {
130
+ to.metrics.fan_in++;
131
+ to.called_by.push(e.from);
132
+ }
133
+ }
134
+ // ---- pass 3: components from directory layout + import dep edges ----------
135
+ const components = deriveComponents(symbols);
136
+ const fileToComponent = new Map();
137
+ for (const c of components)
138
+ for (const f of c._files)
139
+ fileToComponent.set(f, c.id);
140
+ for (const { file, imports } of perFileImports) {
141
+ const fromCmp = fileToComponent.get(file);
142
+ if (!fromCmp)
143
+ continue;
144
+ for (const spec of imports) {
145
+ const target = resolveImport(file, spec, fileSymbols);
146
+ if (!target)
147
+ continue;
148
+ const toCmp = fileToComponent.get(target);
149
+ if (!toCmp || toCmp === fromCmp)
150
+ continue;
151
+ addEdge({
152
+ id: edgeId(fromCmp, toCmp, "depends_on"),
153
+ from: fromCmp, to: toCmp, type: "depends_on",
154
+ reason: `${file} imports ${target}`, strength: 0.6,
155
+ provenance: extracted(0.9, [`${file}:imports:${spec}`]),
156
+ });
157
+ }
158
+ }
159
+ // persist
160
+ store.json.replaceAll("symbols", symbols);
161
+ store.json.replaceAll("edges", edges);
162
+ const compsOut = components.map(({ _files, ...c }) => c);
163
+ store.json.replaceAll("components", compsOut);
164
+ return { files: files.length, symbols: symbols.length, edges: edges.length, components: compsOut.length, skipped };
165
+ }
166
+ // ---- helpers --------------------------------------------------------------
167
+ function listCodeFiles(root) {
168
+ if (isGitRepo(root)) {
169
+ const tracked = trackedFiles(root, CODE_EXTS).map((f) => join(root, f));
170
+ if (tracked.length > 0)
171
+ return tracked; // else fall through (nothing committed yet)
172
+ }
173
+ const out = [];
174
+ const walk = (dir) => {
175
+ for (const name of readdirSync(dir)) {
176
+ if (SKIP_DIRS.has(name))
177
+ continue;
178
+ const abs = join(dir, name);
179
+ const st = statSync(abs);
180
+ if (st.isDirectory())
181
+ walk(abs);
182
+ else if (CODE_EXTS.some((e) => name.endsWith(e)))
183
+ out.push(abs);
184
+ }
185
+ };
186
+ walk(root);
187
+ return out;
188
+ }
189
+ /** Resolve a callee name to a symbol id: prefer same-file, else unique global. */
190
+ function resolveName(name, file, nameIndex, byId) {
191
+ const candidates = nameIndex.get(name);
192
+ if (!candidates || candidates.length === 0)
193
+ return null;
194
+ const sameFile = candidates.filter((id) => byId.get(id)?.file === file);
195
+ if (sameFile.length === 1)
196
+ return sameFile[0];
197
+ if (sameFile.length > 1)
198
+ return null; // ambiguous within the file — don't guess
199
+ if (candidates.length === 1)
200
+ return candidates[0];
201
+ // ambiguous across files — skip to avoid wrong edges (keeps the graph clean)
202
+ return null;
203
+ }
204
+ /** Resolve a relative import specifier to a concrete tracked file path. */
205
+ function resolveImport(fromFile, spec, fileSymbols) {
206
+ if (!spec.startsWith("."))
207
+ return null; // external package
208
+ const base = toPosix(join(dirname(fromFile), spec));
209
+ // Prefer TS source rewrites over the literal `.js` specifier: in a TS repo an
210
+ // import of "./db.js" resolves to db.ts. Only fall back to the literal path.
211
+ const candidates = [
212
+ base.replace(/\.js$/, ".ts"),
213
+ base.replace(/\.js$/, ".tsx"),
214
+ base.replace(/\.jsx$/, ".tsx"),
215
+ base + ".ts",
216
+ base + ".tsx",
217
+ base,
218
+ base + ".js",
219
+ toPosix(join(base, "index.ts")),
220
+ toPosix(join(base, "index.tsx")),
221
+ toPosix(join(base, "index.js")),
222
+ ];
223
+ for (const c of candidates)
224
+ if (fileSymbols.has(c))
225
+ return c;
226
+ return null;
227
+ }
228
+ /** Derive components from the directory layout: the directory immediately under
229
+ * `src/` (or the top-level dir) groups files into a module component. */
230
+ function deriveComponents(symbols) {
231
+ const groups = new Map(); // dir key -> files
232
+ for (const s of symbols) {
233
+ const key = componentDir(s.file);
234
+ (groups.get(key) ?? groups.set(key, new Set()).get(key)).add(s.file);
235
+ }
236
+ const now = new Date().toISOString();
237
+ const out = [];
238
+ for (const [dir, fileSet] of groups) {
239
+ const name = dir.split("/").filter(Boolean).pop() ?? dir;
240
+ out.push({
241
+ id: componentId(dir),
242
+ kind: "module",
243
+ name: capitalize(name),
244
+ responsibility: "",
245
+ paths: [dir.endsWith("/") ? dir + "**" : dir + "/**"],
246
+ status: "active",
247
+ owners: [],
248
+ fragility: 0,
249
+ provenance: inferred(0.5, [dir]),
250
+ created_at: now,
251
+ updated_at: now,
252
+ _files: [...fileSet],
253
+ });
254
+ }
255
+ return out;
256
+ }
257
+ function componentDir(file) {
258
+ const parts = file.split("/");
259
+ if (parts[0] === "src" && parts.length > 2)
260
+ return `src/${parts[1]}`;
261
+ if (parts.length > 1)
262
+ return parts[0];
263
+ return ".";
264
+ }
265
+ function capitalize(s) {
266
+ return s.length ? s[0].toUpperCase() + s.slice(1) : s;
267
+ }
268
+ function toPosix(p) {
269
+ return p.split(/[\\/]/).join(posix.sep);
270
+ }
271
+ //# sourceMappingURL=indexer.js.map
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Deterministic tree-sitter parsing (no LLM). Extracts, per file:
3
+ * - symbols: functions, methods, classes, interfaces, types, arrow-fn consts
4
+ * - imports: module specifiers (for dependency edges)
5
+ * - calls: callee names + byte offset (mapped to the enclosing symbol)
6
+ *
7
+ * Uses NATIVE tree-sitter (synchronous, prebuilt for Node 20 — see decision in
8
+ * the commit history; web-tree-sitter's WASM grammars had an incompatible ABI).
9
+ */
10
+ import Parser from "tree-sitter";
11
+ import TS from "tree-sitter-typescript";
12
+ const { typescript, tsx } = TS;
13
+ /** Extremely common builtin/array/object/string/promise method names. Member
14
+ * calls to these (e.g. `arr.map(...)`) must NOT create call edges to unrelated
15
+ * repo symbols that happen to share the name (DESIGN: keep the graph clean). */
16
+ const BUILTIN_METHODS = new Set([
17
+ "map", "filter", "forEach", "reduce", "find", "findIndex", "some", "every", "includes",
18
+ "push", "pop", "shift", "unshift", "slice", "splice", "concat", "join", "split", "flat", "flatMap",
19
+ "indexOf", "lastIndexOf", "keys", "values", "entries", "sort", "reverse", "fill", "at",
20
+ "get", "set", "has", "add", "delete", "clear",
21
+ "then", "catch", "finally", "all", "race", "resolve", "reject",
22
+ "toString", "valueOf", "toJSON", "hasOwnProperty",
23
+ "replace", "replaceAll", "trim", "trimStart", "trimEnd", "padStart", "padEnd", "startsWith", "endsWith",
24
+ "toLowerCase", "toUpperCase", "charAt", "charCodeAt", "substring", "substr", "repeat", "match", "matchAll",
25
+ "call", "apply", "bind", "test", "exec", "now", "parse", "stringify", "from", "of", "isArray", "assign",
26
+ "log", "error", "warn", "info", "debug",
27
+ ]);
28
+ /** Tree-sitter query capturing every construct we care about in one pass. */
29
+ const QUERY_SRC = `
30
+ (function_declaration name: (identifier) @fn.name) @fn.def
31
+ (generator_function_declaration name: (identifier) @fn.name) @fn.def
32
+ (method_definition name: (property_identifier) @method.name) @method.def
33
+ (class_declaration name: (type_identifier) @class.name) @class.def
34
+ (interface_declaration name: (type_identifier) @iface.name) @iface.def
35
+ (type_alias_declaration name: (type_identifier) @type.name) @type.def
36
+ (variable_declarator
37
+ name: (identifier) @arrow.name
38
+ value: [(arrow_function) (function_expression)]) @arrow.def
39
+ (import_statement source: (string) @import.src)
40
+ (call_expression function: (identifier) @call.id)
41
+ (call_expression function: (member_expression property: (property_identifier) @call.member))
42
+ `;
43
+ const cache = new Map();
44
+ function bundleFor(lang, key) {
45
+ let b = cache.get(key);
46
+ if (!b) {
47
+ const parser = new Parser();
48
+ parser.setLanguage(lang);
49
+ const query = new Parser.Query(lang, QUERY_SRC);
50
+ b = { parser, query };
51
+ cache.set(key, b);
52
+ }
53
+ return b;
54
+ }
55
+ function pickLanguage(file) {
56
+ if (file.endsWith(".tsx") || file.endsWith(".jsx"))
57
+ return { lang: tsx, key: "tsx" };
58
+ if (file.endsWith(".ts") || file.endsWith(".mts") || file.endsWith(".cts"))
59
+ return { lang: typescript, key: "ts" };
60
+ if (file.endsWith(".js") || file.endsWith(".mjs") || file.endsWith(".cjs"))
61
+ return { lang: typescript, key: "ts" };
62
+ return null;
63
+ }
64
+ const STR_QUOTES = /^['"`]|['"`]$/g;
65
+ export function parseSource(file, source) {
66
+ const picked = pickLanguage(file);
67
+ if (!picked)
68
+ return null;
69
+ const { parser, query } = bundleFor(picked.lang, picked.key);
70
+ // The native binding caps its scratch buffer at 32 KB unless bufferSize is
71
+ // given — without this, any source >= 32768 bytes throws "Invalid argument"
72
+ // and would abort the whole index run. Guard with try/catch as a backstop.
73
+ let tree;
74
+ try {
75
+ tree = parser.parse(source, undefined, { bufferSize: Math.max(32 * 1024, source.length * 2 + 1024) });
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ const symbols = [];
81
+ const imports = [];
82
+ const calls = [];
83
+ // group captures by their enclosing @*.def via a quick pass: we record names
84
+ // keyed by the def node, then emit a symbol per def.
85
+ const pendingDefs = new Map();
86
+ const defKind = {
87
+ "fn.def": "function", "method.def": "method", "class.def": "class",
88
+ "iface.def": "interface", "type.def": "type", "arrow.def": "function",
89
+ };
90
+ const nameToDef = {
91
+ "fn.name": "fn.def", "method.name": "method.def", "class.name": "class.def",
92
+ "iface.name": "iface.def", "type.name": "type.def", "arrow.name": "arrow.def",
93
+ };
94
+ for (const cap of query.captures(tree.rootNode)) {
95
+ const cname = cap.name;
96
+ const node = cap.node;
97
+ if (cname.endsWith(".def")) {
98
+ pendingDefs.set(node.id, { kind: defKind[cname], def: node });
99
+ }
100
+ else if (nameToDef[cname]) {
101
+ // name capture: find its parent def node id by walking up to the def type
102
+ const defNode = ascendToDef(node);
103
+ if (defNode) {
104
+ const existing = pendingDefs.get(defNode.id);
105
+ if (existing)
106
+ existing.name = node.text;
107
+ else
108
+ pendingDefs.set(defNode.id, { kind: defKind[nameToDef[cname]], def: defNode, name: node.text });
109
+ }
110
+ }
111
+ else if (cname === "import.src") {
112
+ imports.push(node.text.replace(STR_QUOTES, ""));
113
+ }
114
+ else if (cname === "call.id") {
115
+ calls.push({ callee: node.text, atByte: node.startIndex, member: false });
116
+ }
117
+ else if (cname === "call.member") {
118
+ // skip builtin method names to avoid false edges to similarly-named symbols
119
+ if (!BUILTIN_METHODS.has(node.text))
120
+ calls.push({ callee: node.text, atByte: node.startIndex, member: true });
121
+ }
122
+ }
123
+ for (const { kind, def, name } of pendingDefs.values()) {
124
+ if (!name)
125
+ continue;
126
+ const loc = def.endPosition.row - def.startPosition.row + 1;
127
+ symbols.push({
128
+ name, kind,
129
+ startByte: def.startIndex, endByte: def.endIndex, loc,
130
+ bodyText: def.text.slice(0, 4000),
131
+ });
132
+ }
133
+ symbols.sort((a, b) => a.startByte - b.startByte);
134
+ return { symbols, imports, calls };
135
+ }
136
+ /** Walk up to the nearest node whose type is a definition we recognize. */
137
+ function ascendToDef(node) {
138
+ const defTypes = new Set([
139
+ "function_declaration", "generator_function_declaration", "method_definition",
140
+ "class_declaration", "interface_declaration", "type_alias_declaration", "variable_declarator",
141
+ ]);
142
+ let cur = node.parent;
143
+ while (cur) {
144
+ if (defTypes.has(cur.type))
145
+ return cur;
146
+ cur = cur.parent;
147
+ }
148
+ return null;
149
+ }
150
+ /** Map each call site to the innermost symbol whose byte-range contains it.
151
+ * Keyed by the symbol's `startByte` (a stable per-symbol identity within the
152
+ * file) rather than its name, so two same-named symbols in one file don't merge
153
+ * their call sets. The value maps callee name -> `memberOnly` (true iff every
154
+ * occurrence was a `x.foo()` member call, never a direct `foo()`), so the
155
+ * indexer can resolve member calls conservatively. */
156
+ export function attributeCalls(parsed) {
157
+ const out = new Map();
158
+ for (const call of parsed.calls) {
159
+ let best = null;
160
+ for (const s of parsed.symbols) {
161
+ if (call.atByte >= s.startByte && call.atByte < s.endByte) {
162
+ if (!best || s.endByte - s.startByte < best.endByte - best.startByte)
163
+ best = s;
164
+ }
165
+ }
166
+ if (best && best.name !== call.callee) {
167
+ if (!out.has(best.startByte))
168
+ out.set(best.startByte, new Map());
169
+ const m = out.get(best.startByte);
170
+ const prev = m.get(call.callee);
171
+ m.set(call.callee, prev === undefined ? call.member : prev && call.member);
172
+ }
173
+ }
174
+ return out;
175
+ }
176
+ //# sourceMappingURL=parse.js.map
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Auto-maintained CLAUDE.md (DESIGN.md §7, integration layer 2: "ambient
3
+ * context loaded every session for free"). We own ONLY the region between the
4
+ * HUNCH markers — any user-authored content outside it is preserved verbatim.
5
+ */
6
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ const START = "<!-- HUNCH:START — auto-generated, do not edit by hand -->";
9
+ const END = "<!-- HUNCH:END -->";
10
+ export function renderHunchSection(store) {
11
+ const constraints = store.json
12
+ .loadAll("constraints")
13
+ .sort((a, b) => sev(b.severity) - sev(a.severity))
14
+ .slice(0, 8);
15
+ const counts = {
16
+ decisions: store.json.loadAll("decisions").length,
17
+ bugs: store.json.loadAll("bugs").length,
18
+ constraints: store.json.loadAll("constraints").length,
19
+ components: store.json.loadAll("components").length,
20
+ };
21
+ const lines = [];
22
+ lines.push(START);
23
+ lines.push("## 🧠 Hunch (Engineering Memory)");
24
+ lines.push("");
25
+ lines.push("This repo has **Hunch** — a curated graph of *why* the code is the way it is " +
26
+ "(decisions, bug history, invariants). It currently holds " +
27
+ `**${counts.decisions} decisions, ${counts.bugs} bugs, ${counts.constraints} constraints, ${counts.components} components**.`);
28
+ lines.push("");
29
+ lines.push("**Before reasoning about or editing this codebase, consult Hunch via the `hunch_*` MCP tools:**");
30
+ lines.push("- `hunch_why(target)` — why a file/symbol is shaped this way (decisions, bugs, constraints).");
31
+ lines.push("- `hunch_check_constraints(scope)` — invariants you must not break. **Always run before editing.**");
32
+ lines.push("- `hunch_get_dependents(symbol)` — blast radius before a change.");
33
+ lines.push("- `hunch_bug_lineage(symptom)` — has this bug happened before? what was the root cause?");
34
+ lines.push("- `hunch_query(question)` — free-text search across all of Hunch.");
35
+ lines.push("- `hunch_record_decision(...)` — write back a decision after a non-trivial choice.");
36
+ if (constraints.length) {
37
+ lines.push("");
38
+ lines.push("### ⛔ Top invariants (do not break)");
39
+ for (const c of constraints) {
40
+ lines.push(`- **[${c.severity}]** ${c.statement} _(scope: ${c.scope.join(", ") || "repo"}; ${c.id})_`);
41
+ }
42
+ }
43
+ lines.push("");
44
+ lines.push("_Hunch updates itself from commits and test failures. Records carry provenance + confidence; treat low-confidence items as advisory._");
45
+ lines.push(END);
46
+ return lines.join("\n");
47
+ }
48
+ /** Insert/replace the HUNCH section in CLAUDE.md, preserving everything else. */
49
+ export function updateClaudeMd(root, store) {
50
+ const file = join(root, "CLAUDE.md");
51
+ const section = renderHunchSection(store);
52
+ let content = existsSync(file) ? readFileSync(file, "utf8") : "";
53
+ const iStart = content.indexOf(START);
54
+ const iEnd = content.indexOf(END);
55
+ if (iStart >= 0 && iEnd > iStart) {
56
+ // clean both-marker case: replace in place, preserving surrounding content
57
+ content = content.slice(0, iStart) + section + content.slice(iEnd + END.length);
58
+ }
59
+ else if (iStart >= 0 || iEnd >= 0) {
60
+ // partial/corrupt markers (only one survived, or out of order): strip every
61
+ // stray marker line, then append ONE clean section — never duplicate.
62
+ const body = content.split("\n").filter((l) => !l.includes(START) && !l.includes(END)).join("\n").trimEnd();
63
+ content = body ? `${body}\n\n${section}\n` : `${section}\n`;
64
+ }
65
+ else if (content.trim()) {
66
+ content = `${content.trimEnd()}\n\n${section}\n`;
67
+ }
68
+ else {
69
+ content = `# ${root.split("/").pop()}\n\n${section}\n`;
70
+ }
71
+ writeFileSync(file, content);
72
+ return file;
73
+ }
74
+ function sev(s) {
75
+ return { blocking: 3, warning: 2, advisory: 1 }[s] ?? 0;
76
+ }
77
+ //# sourceMappingURL=claudemd.js.map