@mishasinitcyn/betterrank 0.2.7 → 0.2.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mishasinitcyn/betterrank",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Structural code index with PageRank-ranked repo maps, symbol search, call-graph queries, and dependency analysis. Built on tree-sitter and graphology.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/cache.js CHANGED
@@ -130,6 +130,7 @@ class CodeIndexCache {
130
130
  this.graph = null;
131
131
  this.mtimes = new Map();
132
132
  this.initialized = false;
133
+ this.skipCacheLoadOnce = false;
133
134
  this.extensions = opts.extensions || SUPPORTED_EXTENSIONS;
134
135
  this.ignorePatterns = [...IGNORE_PATTERNS, ...(opts.ignore || [])];
135
136
  }
@@ -142,11 +143,14 @@ class CodeIndexCache {
142
143
  if (!this.initialized) {
143
144
  await this._loadConfig();
144
145
 
145
- const cached = await loadGraph(this.cachePath);
146
- if (cached) {
147
- this.graph = cached.graph;
148
- this.mtimes = cached.mtimes;
146
+ if (!this.skipCacheLoadOnce) {
147
+ const cached = await loadGraph(this.cachePath);
148
+ if (cached) {
149
+ this.graph = cached.graph;
150
+ this.mtimes = cached.mtimes;
151
+ }
149
152
  }
153
+ this.skipCacheLoadOnce = false;
150
154
  this.initialized = true;
151
155
  }
152
156
 
@@ -179,7 +183,11 @@ class CodeIndexCache {
179
183
  updateGraphFiles(this.graph, allRemoved, newSymbols);
180
184
  }
181
185
 
182
- await saveGraph(this.graph, this.mtimes, this.cachePath);
186
+ try {
187
+ await saveGraph(this.graph, this.mtimes, this.cachePath);
188
+ } catch {
189
+ // Cache persistence is best-effort. Keep the in-memory graph usable.
190
+ }
183
191
 
184
192
  if (isColdStart) {
185
193
  const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
@@ -218,6 +226,7 @@ class CodeIndexCache {
218
226
  this.graph = null;
219
227
  this.mtimes = new Map();
220
228
  this.initialized = false;
229
+ this.skipCacheLoadOnce = true;
221
230
 
222
231
  // Delete the cache file
223
232
  try {
package/src/graph.js CHANGED
@@ -3,7 +3,7 @@ const { MultiDirectedGraph } = graphology;
3
3
  import pagerankModule from 'graphology-metrics/centrality/pagerank.js';
4
4
  const pagerank = pagerankModule.default || pagerankModule;
5
5
  import { writeFile, readFile, mkdir } from 'fs/promises';
6
- import { dirname } from 'path';
6
+ import { dirname, posix } from 'path';
7
7
 
8
8
  /**
9
9
  * Build a multi-directed graph from parsed symbol data.
@@ -20,6 +20,18 @@ import { dirname } from 'path';
20
20
  // Names with more definitions than this (main, run, get, close, etc.) are
21
21
  // too ambiguous to provide useful structural signal.
22
22
  const AMBIGUITY_CAP = 5;
23
+ const IMPORT_RESOLVE_EXTENSIONS = [
24
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
25
+ '.py', '.rs', '.go', '.rb', '.java',
26
+ '.c', '.h', '.cpp', '.hpp', '.cc', '.cs', '.php',
27
+ ];
28
+ const FOCUS_MAX_HOPS = 2;
29
+ const FOCUS_DISTANCE_WEIGHTS = new Map([
30
+ [0, 250],
31
+ [1, 12],
32
+ [2, 2.5],
33
+ ]);
34
+ const FOCUS_DEFAULT_WEIGHT = 0.15;
23
35
 
24
36
  /**
25
37
  * Disambiguate which targets a reference should wire to.
@@ -45,51 +57,97 @@ function disambiguateTargets(targets, sourceFile, graph) {
45
57
  return targets;
46
58
  }
47
59
 
48
- function buildGraph(allSymbols) {
49
- const graph = new MultiDirectedGraph({ allowSelfLoops: false });
60
+ function normalizeFilePath(filePath) {
61
+ return filePath.replace(/\\/g, '/');
62
+ }
50
63
 
51
- for (const { file, definitions } of allSymbols) {
52
- graph.mergeNode(file, { type: 'file', symbolCount: definitions.length });
64
+ function buildDefinitionIndex(graph) {
65
+ const defIndex = new Map();
53
66
 
54
- for (const def of definitions) {
55
- const symbolKey = `${file}::${def.name}`;
56
- graph.mergeNode(symbolKey, {
57
- type: 'symbol',
58
- kind: def.kind,
59
- name: def.name,
60
- file,
61
- lineStart: def.lineStart,
62
- lineEnd: def.lineEnd,
63
- signature: def.signature,
64
- astProfile: def.astProfile || null,
65
- paramNames: def.paramNames || null,
66
- localRefs: def.localRefs || null,
67
- });
68
- graph.addEdge(file, symbolKey, { type: 'DEFINES' });
69
- }
67
+ graph.forEachNode((node, attrs) => {
68
+ if (attrs.type !== 'symbol') return;
69
+ if (!defIndex.has(attrs.name)) defIndex.set(attrs.name, []);
70
+ defIndex.get(attrs.name).push(node);
71
+ });
72
+
73
+ return defIndex;
74
+ }
75
+
76
+ function buildFileLookup(graph) {
77
+ const fileLookup = new Map();
78
+
79
+ graph.forEachNode((node, attrs) => {
80
+ if (attrs.type !== 'file') return;
81
+ fileLookup.set(normalizeFilePath(node), node);
82
+ });
83
+
84
+ return fileLookup;
85
+ }
86
+
87
+ function resolveImportBase(sourceFile, specifier) {
88
+ const normalizedSource = normalizeFilePath(sourceFile);
89
+ const normalizedSpecifier = normalizeFilePath(specifier);
90
+ if (!normalizedSpecifier || normalizedSpecifier.startsWith('node:')) return null;
91
+
92
+ if (normalizedSpecifier.startsWith('.')) {
93
+ return posix.normalize(posix.join(posix.dirname(normalizedSource), normalizedSpecifier));
70
94
  }
71
95
 
72
- // Build a name→symbolKey index for wiring references
73
- const defIndex = new Map();
74
- for (const { file, definitions } of allSymbols) {
75
- for (const def of definitions) {
76
- const key = `${file}::${def.name}`;
77
- if (!defIndex.has(def.name)) defIndex.set(def.name, []);
78
- defIndex.get(def.name).push(key);
96
+ if (normalizedSpecifier.startsWith('~/') || normalizedSpecifier.startsWith('@/')) {
97
+ return posix.normalize(posix.join('src', normalizedSpecifier.slice(2)));
98
+ }
99
+
100
+ if (normalizedSpecifier.startsWith('/')) {
101
+ return posix.normalize(normalizedSpecifier.slice(1));
102
+ }
103
+
104
+ if (normalizedSpecifier.startsWith('src/')) {
105
+ return posix.normalize(normalizedSpecifier);
106
+ }
107
+
108
+ return null;
109
+ }
110
+
111
+ function hasExplicitImportExtension(filePath) {
112
+ return IMPORT_RESOLVE_EXTENSIONS.some(ext => filePath.endsWith(ext));
113
+ }
114
+
115
+ function resolveImportTargetFile(sourceFile, specifier, fileLookup) {
116
+ const importBase = resolveImportBase(sourceFile, specifier);
117
+ if (!importBase) return null;
118
+
119
+ const candidates = [importBase];
120
+ if (!hasExplicitImportExtension(importBase)) {
121
+ for (const ext of IMPORT_RESOLVE_EXTENSIONS) {
122
+ candidates.push(`${importBase}${ext}`);
123
+ }
124
+ for (const ext of IMPORT_RESOLVE_EXTENSIONS) {
125
+ candidates.push(posix.join(importBase, `index${ext}`));
79
126
  }
80
127
  }
81
128
 
82
- // Dedup: one REFERENCES edge and one IMPORTS edge per unique (source, target) pair
83
- const addedRefs = new Set();
84
- const addedImports = new Set();
129
+ for (const candidate of candidates) {
130
+ const resolved = fileLookup.get(normalizeFilePath(candidate));
131
+ if (resolved) return resolved;
132
+ }
133
+
134
+ return null;
135
+ }
85
136
 
86
- for (const { file, references } of allSymbols) {
137
+ function wireSymbolReferences(records, graph, defIndex, addedRefs, addedImports) {
138
+ for (const { file, references } of records) {
87
139
  for (const ref of references) {
88
140
  const targets = defIndex.get(ref.name);
89
141
  if (!targets) continue;
90
142
 
91
- // Disambiguate: resolve which targets to actually wire
92
- const resolvedTargets = disambiguateTargets(targets, file, graph);
143
+ const filteredTargets = ref.kind === 'property'
144
+ ? targets.filter(target => {
145
+ try { return graph.getNodeAttribute(target, 'kind') === 'property'; } catch { return false; }
146
+ })
147
+ : targets;
148
+ if (filteredTargets.length === 0) continue;
149
+
150
+ const resolvedTargets = disambiguateTargets(filteredTargets, file, graph);
93
151
 
94
152
  for (const target of resolvedTargets) {
95
153
  const targetFile = graph.getNodeAttribute(target, 'file');
@@ -110,35 +168,25 @@ function buildGraph(allSymbols) {
110
168
  }
111
169
  }
112
170
  }
113
-
114
- return graph;
115
171
  }
116
172
 
117
- /**
118
- * Incrementally update the graph: remove all nodes for the given files,
119
- * then re-add from fresh parse results.
120
- */
121
- function updateGraphFiles(graph, removedFiles, newSymbols) {
122
- for (const filePath of removedFiles) {
123
- removeFileNodes(graph, filePath);
124
- }
173
+ function wireExplicitImportEdges(records, graph, fileLookup, addedImports) {
174
+ for (const { file, imports = [] } of records) {
175
+ for (const entry of imports) {
176
+ const targetFile = resolveImportTargetFile(file, entry.path, fileLookup);
177
+ if (!targetFile || targetFile === file) continue;
125
178
 
126
- // Re-add from newSymbols using the same logic as buildGraph,
127
- // but operating on the existing graph.
128
- const defIndex = new Map();
179
+ const impKey = `${file}\0${targetFile}`;
180
+ if (addedImports.has(impKey)) continue;
129
181
 
130
- // Rebuild defIndex from the entire graph (existing + new)
131
- graph.forEachNode((node, attrs) => {
132
- if (attrs.type === 'symbol') {
133
- if (!defIndex.has(attrs.name)) defIndex.set(attrs.name, []);
134
- defIndex.get(attrs.name).push(node);
182
+ addedImports.add(impKey);
183
+ graph.addEdge(file, targetFile, { type: 'IMPORTS' });
135
184
  }
136
- });
137
-
138
- const addedRefs = new Set();
139
- const addedImports = new Set();
185
+ }
186
+ }
140
187
 
141
- for (const { file, definitions, references } of newSymbols) {
188
+ function mergeDefinitions(graph, records) {
189
+ for (const { file, definitions } of records) {
142
190
  graph.mergeNode(file, { type: 'file', symbolCount: definitions.length });
143
191
 
144
192
  for (const def of definitions) {
@@ -156,36 +204,65 @@ function updateGraphFiles(graph, removedFiles, newSymbols) {
156
204
  localRefs: def.localRefs || null,
157
205
  });
158
206
  graph.addEdge(file, symbolKey, { type: 'DEFINES' });
159
-
160
- if (!defIndex.has(def.name)) defIndex.set(def.name, []);
161
- defIndex.get(def.name).push(symbolKey);
162
207
  }
208
+ }
209
+ }
163
210
 
164
- for (const ref of references) {
165
- const targets = defIndex.get(ref.name);
166
- if (!targets) continue;
211
+ function buildGraph(allSymbols) {
212
+ const graph = new MultiDirectedGraph({ allowSelfLoops: false });
213
+ mergeDefinitions(graph, allSymbols);
214
+ const defIndex = buildDefinitionIndex(graph);
167
215
 
168
- const resolvedTargets = disambiguateTargets(targets, file, graph);
216
+ // Dedup: one REFERENCES edge and one IMPORTS edge per unique (source, target) pair
217
+ const addedRefs = new Set();
218
+ const addedImports = new Set();
219
+ wireSymbolReferences(allSymbols, graph, defIndex, addedRefs, addedImports);
220
+ wireExplicitImportEdges(allSymbols, graph, buildFileLookup(graph), addedImports);
169
221
 
170
- for (const target of resolvedTargets) {
171
- const targetFile = graph.getNodeAttribute(target, 'file');
222
+ return graph;
223
+ }
172
224
 
173
- const refKey = `${file}\0${target}`;
174
- if (!addedRefs.has(refKey)) {
175
- addedRefs.add(refKey);
176
- graph.addEdge(file, target, { type: 'REFERENCES' });
177
- }
225
+ /**
226
+ * Incrementally update the graph: remove all nodes for the given files,
227
+ * then re-add from fresh parse results.
228
+ */
229
+ function updateGraphFiles(graph, removedFiles, newSymbols) {
230
+ const changedFiles = new Map(newSymbols.map(record => [record.file, record]));
178
231
 
179
- if (targetFile !== file) {
180
- const impKey = `${file}\0${targetFile}`;
181
- if (!addedImports.has(impKey)) {
182
- addedImports.add(impKey);
183
- graph.addEdge(file, targetFile, { type: 'IMPORTS' });
184
- }
185
- }
186
- }
232
+ for (const filePath of removedFiles) {
233
+ const nextRecord = changedFiles.get(filePath);
234
+ if (!nextRecord) {
235
+ removeFileNodes(graph, filePath);
236
+ continue;
237
+ }
238
+
239
+ const outgoingEdges = [];
240
+ graph.forEachOutEdge(filePath, edge => {
241
+ outgoingEdges.push(edge);
242
+ });
243
+ for (const edge of outgoingEdges) {
244
+ graph.dropEdge(edge);
245
+ }
246
+
247
+ const nextNames = new Set(nextRecord.definitions.map(def => def.name));
248
+ const staleNodes = [];
249
+ graph.forEachNode((node, attrs) => {
250
+ if (attrs.type !== 'symbol' || attrs.file !== filePath) return;
251
+ if (!nextNames.has(attrs.name)) staleNodes.push(node);
252
+ });
253
+ for (const node of staleNodes) {
254
+ graph.dropNode(node);
187
255
  }
188
256
  }
257
+
258
+ mergeDefinitions(graph, newSymbols);
259
+
260
+ const defIndex = buildDefinitionIndex(graph);
261
+ const fileLookup = buildFileLookup(graph);
262
+ const addedRefs = new Set();
263
+ const addedImports = new Set();
264
+ wireSymbolReferences(newSymbols, graph, defIndex, addedRefs, addedImports);
265
+ wireExplicitImportEdges(newSymbols, graph, fileLookup, addedImports);
189
266
  }
190
267
 
191
268
  function removeFileNodes(graph, filePath) {
@@ -200,6 +277,56 @@ function removeFileNodes(graph, filePath) {
200
277
  }
201
278
  }
202
279
 
280
+ function buildFocusDistanceMap(graph, focusFiles, maxHops = FOCUS_MAX_HOPS) {
281
+ const distances = new Map();
282
+ const queue = [];
283
+
284
+ for (const file of focusFiles) {
285
+ if (!graph.hasNode(file)) continue;
286
+ const attrs = graph.getNodeAttributes(file);
287
+ if (attrs.type !== 'file') continue;
288
+ distances.set(file, 0);
289
+ queue.push(file);
290
+ }
291
+
292
+ for (let i = 0; i < queue.length; i++) {
293
+ const current = queue[i];
294
+ const currentDistance = distances.get(current);
295
+ if (currentDistance >= maxHops) continue;
296
+
297
+ const visitNeighbor = neighbor => {
298
+ if (!graph.hasNode(neighbor)) return;
299
+ const neighborAttrs = graph.getNodeAttributes(neighbor);
300
+ if (neighborAttrs.type !== 'file') return;
301
+
302
+ const nextDistance = currentDistance + 1;
303
+ const existing = distances.get(neighbor);
304
+ if (existing !== undefined && existing <= nextDistance) return;
305
+ distances.set(neighbor, nextDistance);
306
+ queue.push(neighbor);
307
+ };
308
+
309
+ graph.forEachOutEdge(current, (_edge, attrs, _source, target) => {
310
+ if (attrs.type !== 'IMPORTS') return;
311
+ visitNeighbor(target);
312
+ });
313
+
314
+ graph.forEachInEdge(current, (_edge, attrs, source) => {
315
+ if (attrs.type !== 'IMPORTS') return;
316
+ visitNeighbor(source);
317
+ });
318
+ }
319
+
320
+ return distances;
321
+ }
322
+
323
+ function getFocusWeight(filePath, focusDistances) {
324
+ if (!focusDistances || focusDistances.size === 0) return 1.0;
325
+ const distance = focusDistances.get(filePath);
326
+ if (distance === undefined) return FOCUS_DEFAULT_WEIGHT;
327
+ return FOCUS_DISTANCE_WEIGHTS.get(distance) || FOCUS_DEFAULT_WEIGHT;
328
+ }
329
+
203
330
  // Path-tier dampening: files outside core source directories get their
204
331
  // PageRank scores multiplied by a fraction. This prevents scripts, tests,
205
332
  // and temp files from dominating the map output over actual source code.
@@ -235,6 +362,7 @@ function rankedSymbols(graph, focusFiles = [], pathTiers = DEFAULT_PATH_TIERS) {
235
362
  if (graph.order === 0) return [];
236
363
 
237
364
  const g = graph.copy();
365
+ const focusDistances = focusFiles.length > 0 ? buildFocusDistanceMap(graph, focusFiles) : null;
238
366
 
239
367
  if (focusFiles.length > 0) {
240
368
  g.mergeNode('__focus__', { type: 'virtual' });
@@ -266,7 +394,7 @@ function rankedSymbols(graph, focusFiles = [], pathTiers = DEFAULT_PATH_TIERS) {
266
394
  .map(([key, score]) => {
267
395
  try {
268
396
  const file = graph.getNodeAttribute(key, 'file');
269
- return [key, score * getPathWeight(file, pathTiers)];
397
+ return [key, score * getPathWeight(file, pathTiers) * getFocusWeight(file, focusDistances)];
270
398
  } catch {
271
399
  return [key, score];
272
400
  }
package/src/index.js CHANGED
@@ -578,6 +578,9 @@ class CodeIndex {
578
578
  // Match call sites: symbol followed by ( — avoids string literals and definitions
579
579
  const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
580
580
  const callPattern = new RegExp(`(?<![a-zA-Z0-9_])${escaped}\\s*\\(`);
581
+ const jsxPattern = new RegExp(`<\\s*${escaped}(?=[\\s>/.]|$)`);
582
+ const memberPattern = new RegExp(`\\.\\s*${escaped}\\b`);
583
+ const bracketPattern = new RegExp(`\\[\\s*['"\`]${escaped}['"\`]\\s*\\]`);
581
584
  // Fallback: import/from lines that reference the symbol
582
585
  const importPattern = new RegExp(`(?:import|from)\\s.*\\b${escaped}\\b`);
583
586
 
@@ -604,7 +607,13 @@ class CodeIndex {
604
607
  if (inDef) continue;
605
608
 
606
609
  const line = lines[i];
607
- if (!callPattern.test(line) && !importPattern.test(line)) continue;
610
+ if (
611
+ !callPattern.test(line) &&
612
+ !jsxPattern.test(line) &&
613
+ !memberPattern.test(line) &&
614
+ !bracketPattern.test(line) &&
615
+ !importPattern.test(line)
616
+ ) continue;
608
617
 
609
618
  const start = Math.max(0, i - context);
610
619
  const end = Math.min(lines.length - 1, i + context);
package/src/parser.js CHANGED
@@ -82,6 +82,11 @@ const DEF_QUERIES = {
82
82
  (export_statement declaration: (function_declaration name: (identifier) @name) @definition)
83
83
  (export_statement declaration: (class_declaration name: (identifier) @name) @definition)
84
84
  (export_statement declaration: (lexical_declaration (variable_declarator name: (identifier) @name value: (arrow_function) @_val)) @definition)
85
+ (export_statement declaration: (lexical_declaration (variable_declarator name: (identifier) @name value: (function_expression) @_val)) @definition)
86
+ (export_statement declaration: (lexical_declaration (variable_declarator name: (identifier) @name value: (call_expression) @_val)) @definition)
87
+ (export_statement declaration: (lexical_declaration (variable_declarator name: (identifier) @name value: (new_expression) @_val)) @definition)
88
+ (export_statement declaration: (lexical_declaration (variable_declarator name: (identifier) @name value: (object) @_val)) @definition)
89
+ (export_statement declaration: (lexical_declaration (variable_declarator name: (identifier) @name value: (array) @_val)) @definition)
85
90
  `,
86
91
 
87
92
  typescript: `
@@ -95,6 +100,11 @@ const DEF_QUERIES = {
95
100
  (export_statement declaration: (function_declaration name: (identifier) @name) @definition)
96
101
  (export_statement declaration: (class_declaration name: (type_identifier) @name) @definition)
97
102
  (export_statement declaration: (lexical_declaration (variable_declarator name: (identifier) @name value: (arrow_function) @_val)) @definition)
103
+ (export_statement declaration: (lexical_declaration (variable_declarator name: (identifier) @name value: (function_expression) @_val)) @definition)
104
+ (export_statement declaration: (lexical_declaration (variable_declarator name: (identifier) @name value: (call_expression) @_val)) @definition)
105
+ (export_statement declaration: (lexical_declaration (variable_declarator name: (identifier) @name value: (new_expression) @_val)) @definition)
106
+ (export_statement declaration: (lexical_declaration (variable_declarator name: (identifier) @name value: (object) @_val)) @definition)
107
+ (export_statement declaration: (lexical_declaration (variable_declarator name: (identifier) @name value: (array) @_val)) @definition)
98
108
  (export_statement declaration: (interface_declaration name: (type_identifier) @name) @definition)
99
109
  (export_statement declaration: (type_alias_declaration name: (type_identifier) @name) @definition)
100
110
  (export_statement declaration: (enum_declaration name: (identifier) @name) @definition)
@@ -178,15 +188,33 @@ const REF_QUERIES = {
178
188
  (call_expression function: (identifier) @ref)
179
189
  (import_specifier name: (identifier) @ref)
180
190
  (import_clause (identifier) @ref)
191
+ (member_expression property: (property_identifier) @prop_ref)
192
+ (jsx_opening_element (identifier) @jsx_ref)
193
+ (jsx_self_closing_element (identifier) @jsx_ref)
194
+ (jsx_opening_element (member_expression (identifier) @jsx_ref))
195
+ (jsx_self_closing_element (member_expression (identifier) @jsx_ref))
181
196
  `,
182
197
 
183
198
  typescript: `
184
199
  (call_expression function: (identifier) @ref)
185
200
  (import_specifier name: (identifier) @ref)
186
201
  (import_clause (identifier) @ref)
202
+ (member_expression property: (property_identifier) @prop_ref)
187
203
  (type_identifier) @ref
188
204
  `,
189
205
 
206
+ tsx: `
207
+ (call_expression function: (identifier) @ref)
208
+ (import_specifier name: (identifier) @ref)
209
+ (import_clause (identifier) @ref)
210
+ (member_expression property: (property_identifier) @prop_ref)
211
+ (type_identifier) @ref
212
+ (jsx_opening_element (identifier) @jsx_ref)
213
+ (jsx_self_closing_element (identifier) @jsx_ref)
214
+ (jsx_opening_element (member_expression (identifier) @jsx_ref))
215
+ (jsx_self_closing_element (member_expression (identifier) @jsx_ref))
216
+ `,
217
+
190
218
  python: `
191
219
  (call function: (identifier) @ref)
192
220
  (decorator (identifier) @ref)
@@ -213,7 +241,21 @@ const REF_QUERIES = {
213
241
  `,
214
242
  };
215
243
 
216
- REF_QUERIES.tsx = REF_QUERIES.typescript;
244
+ const IMPORT_QUERIES = {
245
+ javascript: `
246
+ (import_statement source: (string) @import_path)
247
+ (export_statement source: (string) @import_path)
248
+ (call_expression function: (identifier) @require_fn arguments: (arguments (string) @import_path))
249
+ `,
250
+
251
+ typescript: `
252
+ (import_statement source: (string) @import_path)
253
+ (export_statement source: (string) @import_path)
254
+ (call_expression function: (identifier) @require_fn arguments: (arguments (string) @import_path))
255
+ `,
256
+ };
257
+
258
+ IMPORT_QUERIES.tsx = IMPORT_QUERIES.typescript;
217
259
 
218
260
  const KIND_MAP = {
219
261
  function_declaration: 'function',
@@ -248,8 +290,12 @@ const KIND_MAP = {
248
290
  };
249
291
 
250
292
  const OUTLINE_EXTRA_LANGUAGES = new Set(['javascript', 'typescript', 'tsx']);
293
+ const INDEX_EXTRA_LANGUAGES = new Set(['javascript', 'typescript', 'tsx']);
251
294
  const OUTLINE_BODY_TYPES = new Set(['statement_block', 'class_body', 'object', 'array']);
252
295
  const OUTLINE_CALL_TYPES = new Set(['call_expression', 'new_expression']);
296
+ const SEMANTIC_CONTAINER_NAME_RE = /(router|routes|handlers|actions|reducers|selectors|queries|mutations|registry|registries|map|maps|config|configs|options|endpoints|procedures)$/i;
297
+ const NON_SEMANTIC_CONTAINER_NAME_RE = /(schema|shape|validator|payload|params?|props?|input|output|response|request|dto)$/i;
298
+ const NON_SEMANTIC_CALLEE_NAME_RE = /^(object|array|enum|union|literal|record|tuple|pick|omit|extend|merge|intersection|partial|strictObject|looseObject)$/i;
253
299
 
254
300
  /**
255
301
  * Walk an AST subtree and count node types that reveal structural shape.
@@ -449,6 +495,111 @@ function hasMultilinePairChildren(node) {
449
495
  return false;
450
496
  }
451
497
 
498
+ function isTopLevelVariableDeclarator(node) {
499
+ if (!node || node.type !== 'variable_declarator') return false;
500
+ const decl = node.parent;
501
+ if (!decl || decl.type !== 'lexical_declaration') return false;
502
+ const container = decl.parent;
503
+ return !!container && (container.type === 'program' || container.type === 'export_statement');
504
+ }
505
+
506
+ function extractDeclaratorName(node) {
507
+ const nameNode = node?.childForFieldName('name');
508
+ return nameNode?.type === 'identifier' ? nameNode.text : null;
509
+ }
510
+
511
+ function extractCalleeLeafName(node) {
512
+ if (!node) return null;
513
+
514
+ if (['identifier', 'property_identifier', 'private_property_identifier', 'type_identifier'].includes(node.type)) {
515
+ return node.text;
516
+ }
517
+
518
+ if (node.type === 'member_expression') {
519
+ const propertyNode = node.childForFieldName('property');
520
+ if (propertyNode) return extractCalleeLeafName(propertyNode);
521
+ return extractCalleeLeafName(node.namedChild(node.namedChildCount - 1));
522
+ }
523
+
524
+ return null;
525
+ }
526
+
527
+ function extractCallLikeCalleeName(node) {
528
+ if (!node || (node.type !== 'call_expression' && node.type !== 'new_expression')) return null;
529
+
530
+ const calleeNode = node.childForFieldName('function')
531
+ || node.childForFieldName('constructor')
532
+ || node.namedChild(0);
533
+ return extractCalleeLeafName(calleeNode);
534
+ }
535
+
536
+ function shouldIndexSemanticObjectMembers(node) {
537
+ if (!isTopLevelVariableDeclarator(node)) return false;
538
+
539
+ const variableName = extractDeclaratorName(node);
540
+ const valueNode = node.childForFieldName('value');
541
+ const calleeName = extractCallLikeCalleeName(valueNode);
542
+
543
+ const hasSemanticVariableName = !!variableName
544
+ && SEMANTIC_CONTAINER_NAME_RE.test(variableName)
545
+ && !NON_SEMANTIC_CONTAINER_NAME_RE.test(variableName);
546
+ const hasSemanticCalleeName = !!calleeName && !NON_SEMANTIC_CALLEE_NAME_RE.test(calleeName);
547
+
548
+ if (valueNode?.type === 'object') {
549
+ return hasSemanticVariableName;
550
+ }
551
+
552
+ if (valueNode?.type === 'call_expression' || valueNode?.type === 'new_expression') {
553
+ return hasSemanticVariableName || hasSemanticCalleeName;
554
+ }
555
+
556
+ return false;
557
+ }
558
+
559
+ function collectDirectPairDefinitions(node, filePath, langName) {
560
+ if (!node || node.type !== 'object') return [];
561
+
562
+ const definitions = [];
563
+ for (let i = 0; i < node.namedChildCount; i++) {
564
+ const child = node.namedChild(i);
565
+ if (child.type !== 'pair') continue;
566
+
567
+ const name = extractPairName(child);
568
+ if (!name) continue;
569
+ if (child.endPosition.row <= child.startPosition.row) continue;
570
+
571
+ definitions.push(createDefinition(name, child, filePath, langName, {
572
+ bodyStartLine: child.startPosition.row + 2,
573
+ kind: 'property',
574
+ }));
575
+ }
576
+
577
+ return definitions;
578
+ }
579
+
580
+ function collectIndexDefinitions(rootNode, filePath, langName) {
581
+ if (!INDEX_EXTRA_LANGUAGES.has(langName)) return [];
582
+
583
+ const definitions = [];
584
+
585
+ function visit(node) {
586
+ if (node.type === 'variable_declarator' && shouldIndexSemanticObjectMembers(node)) {
587
+ const bodyNode = findBodyNode(node);
588
+ if (bodyNode && bodyNode.type === 'object') {
589
+ definitions.push(...collectDirectPairDefinitions(bodyNode, filePath, langName));
590
+ }
591
+ return;
592
+ }
593
+
594
+ for (let i = 0; i < node.namedChildCount; i++) {
595
+ visit(node.namedChild(i));
596
+ }
597
+ }
598
+
599
+ visit(rootNode);
600
+ return definitions;
601
+ }
602
+
452
603
  function collectOutlineDefinitions(rootNode, filePath, langName) {
453
604
  if (!OUTLINE_EXTRA_LANGUAGES.has(langName)) return [];
454
605
 
@@ -522,8 +673,28 @@ function extractSignature(node, langName) {
522
673
  return sig.length > 200 ? sig.substring(0, 200) + '...' : sig;
523
674
  }
524
675
 
676
+ function normalizeReferenceCapture(capture) {
677
+ if (!capture) return null;
678
+ const text = capture.node.text;
679
+ if (!text) return null;
680
+
681
+ if (capture.name === 'jsx_ref') {
682
+ return /^[A-Z]/.test(text) ? text : null;
683
+ }
684
+
685
+ return text;
686
+ }
687
+
688
+ function normalizeImportPathCapture(capture) {
689
+ if (!capture) return null;
690
+ const text = capture.node.text;
691
+ if (!text) return null;
692
+
693
+ return text.replace(/^['"`]|['"`]$/g, '');
694
+ }
695
+
525
696
  /**
526
- * Parse a single source file and extract definitions + references.
697
+ * Parse a single source file and extract definitions, references, and imports.
527
698
  * Returns null if the language is unsupported.
528
699
  */
529
700
  function parseFile(filePath, source, { includeOutlineDefinitions = false } = {}) {
@@ -540,6 +711,7 @@ function parseFile(filePath, source, { includeOutlineDefinitions = false } = {})
540
711
 
541
712
  const definitions = [];
542
713
  const references = [];
714
+ const imports = [];
543
715
 
544
716
  const defQueryStr = DEF_QUERIES[langName] || null;
545
717
  if (defQueryStr) {
@@ -558,6 +730,8 @@ function parseFile(filePath, source, { includeOutlineDefinitions = false } = {})
558
730
  }
559
731
  }
560
732
 
733
+ definitions.push(...collectIndexDefinitions(tree.rootNode, filePath, langName));
734
+
561
735
  if (includeOutlineDefinitions) {
562
736
  definitions.push(...collectOutlineDefinitions(tree.rootNode, filePath, langName));
563
737
  }
@@ -567,12 +741,39 @@ function parseFile(filePath, source, { includeOutlineDefinitions = false } = {})
567
741
  try {
568
742
  const refQuery = new Parser.Query(lang, refQueryStr);
569
743
  for (const match of refQuery.matches(tree.rootNode)) {
570
- const refCapture = match.captures.find(c => c.name === 'ref');
571
- if (!refCapture) continue;
572
- references.push({
573
- name: refCapture.node.text,
744
+ for (const capture of match.captures) {
745
+ if (capture.name !== 'ref' && capture.name !== 'jsx_ref' && capture.name !== 'prop_ref') continue;
746
+ const name = normalizeReferenceCapture(capture);
747
+ if (!name) continue;
748
+ references.push({
749
+ name,
750
+ kind: capture.name === 'prop_ref' ? 'property' : 'symbol',
751
+ file: filePath,
752
+ line: capture.node.startPosition.row + 1,
753
+ });
754
+ }
755
+ }
756
+ } catch (e) {
757
+ // Degrade gracefully
758
+ }
759
+ }
760
+
761
+ const importQueryStr = IMPORT_QUERIES[langName] || null;
762
+ if (importQueryStr) {
763
+ try {
764
+ const importQuery = new Parser.Query(lang, importQueryStr);
765
+ for (const match of importQuery.matches(tree.rootNode)) {
766
+ const requireCapture = match.captures.find(c => c.name === 'require_fn');
767
+ if (requireCapture && requireCapture.node.text !== 'require') continue;
768
+
769
+ const pathCapture = match.captures.find(c => c.name === 'import_path');
770
+ const path = normalizeImportPathCapture(pathCapture);
771
+ if (!path) continue;
772
+
773
+ imports.push({
774
+ path,
574
775
  file: filePath,
575
- line: refCapture.node.startPosition.row + 1,
776
+ line: pathCapture.node.startPosition.row + 1,
576
777
  });
577
778
  }
578
779
  } catch (e) {
@@ -601,7 +802,7 @@ function parseFile(filePath, source, { includeOutlineDefinitions = false } = {})
601
802
 
602
803
  // No tree.delete()/parser.delete() needed — native GC handles cleanup
603
804
 
604
- return { file: filePath, definitions, references };
805
+ return { file: filePath, definitions, references, imports };
605
806
  }
606
807
 
607
808
  export { parseFile, buildAstProfile, extractParamNames, SUPPORTED_EXTENSIONS, LANG_MAP };