@mishasinitcyn/betterrank 0.2.6 → 0.2.8

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.6",
3
+ "version": "0.2.8",
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,11 @@ 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
+ ];
23
28
 
24
29
  /**
25
30
  * Disambiguate which targets a reference should wire to.
@@ -45,50 +50,89 @@ function disambiguateTargets(targets, sourceFile, graph) {
45
50
  return targets;
46
51
  }
47
52
 
48
- function buildGraph(allSymbols) {
49
- const graph = new MultiDirectedGraph({ allowSelfLoops: false });
53
+ function normalizeFilePath(filePath) {
54
+ return filePath.replace(/\\/g, '/');
55
+ }
50
56
 
51
- for (const { file, definitions } of allSymbols) {
52
- graph.mergeNode(file, { type: 'file', symbolCount: definitions.length });
57
+ function buildDefinitionIndex(graph) {
58
+ const defIndex = new Map();
53
59
 
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
- }
60
+ graph.forEachNode((node, attrs) => {
61
+ if (attrs.type !== 'symbol') return;
62
+ if (!defIndex.has(attrs.name)) defIndex.set(attrs.name, []);
63
+ defIndex.get(attrs.name).push(node);
64
+ });
65
+
66
+ return defIndex;
67
+ }
68
+
69
+ function buildFileLookup(graph) {
70
+ const fileLookup = new Map();
71
+
72
+ graph.forEachNode((node, attrs) => {
73
+ if (attrs.type !== 'file') return;
74
+ fileLookup.set(normalizeFilePath(node), node);
75
+ });
76
+
77
+ return fileLookup;
78
+ }
79
+
80
+ function resolveImportBase(sourceFile, specifier) {
81
+ const normalizedSource = normalizeFilePath(sourceFile);
82
+ const normalizedSpecifier = normalizeFilePath(specifier);
83
+ if (!normalizedSpecifier || normalizedSpecifier.startsWith('node:')) return null;
84
+
85
+ if (normalizedSpecifier.startsWith('.')) {
86
+ return posix.normalize(posix.join(posix.dirname(normalizedSource), normalizedSpecifier));
70
87
  }
71
88
 
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);
89
+ if (normalizedSpecifier.startsWith('~/') || normalizedSpecifier.startsWith('@/')) {
90
+ return posix.normalize(posix.join('src', normalizedSpecifier.slice(2)));
91
+ }
92
+
93
+ if (normalizedSpecifier.startsWith('/')) {
94
+ return posix.normalize(normalizedSpecifier.slice(1));
95
+ }
96
+
97
+ if (normalizedSpecifier.startsWith('src/')) {
98
+ return posix.normalize(normalizedSpecifier);
99
+ }
100
+
101
+ return null;
102
+ }
103
+
104
+ function hasExplicitImportExtension(filePath) {
105
+ return IMPORT_RESOLVE_EXTENSIONS.some(ext => filePath.endsWith(ext));
106
+ }
107
+
108
+ function resolveImportTargetFile(sourceFile, specifier, fileLookup) {
109
+ const importBase = resolveImportBase(sourceFile, specifier);
110
+ if (!importBase) return null;
111
+
112
+ const candidates = [importBase];
113
+ if (!hasExplicitImportExtension(importBase)) {
114
+ for (const ext of IMPORT_RESOLVE_EXTENSIONS) {
115
+ candidates.push(`${importBase}${ext}`);
116
+ }
117
+ for (const ext of IMPORT_RESOLVE_EXTENSIONS) {
118
+ candidates.push(posix.join(importBase, `index${ext}`));
79
119
  }
80
120
  }
81
121
 
82
- // Dedup: one REFERENCES edge and one IMPORTS edge per unique (source, target) pair
83
- const addedRefs = new Set();
84
- const addedImports = new Set();
122
+ for (const candidate of candidates) {
123
+ const resolved = fileLookup.get(normalizeFilePath(candidate));
124
+ if (resolved) return resolved;
125
+ }
126
+
127
+ return null;
128
+ }
85
129
 
86
- for (const { file, references } of allSymbols) {
130
+ function wireSymbolReferences(records, graph, defIndex, addedRefs, addedImports) {
131
+ for (const { file, references } of records) {
87
132
  for (const ref of references) {
88
133
  const targets = defIndex.get(ref.name);
89
134
  if (!targets) continue;
90
135
 
91
- // Disambiguate: resolve which targets to actually wire
92
136
  const resolvedTargets = disambiguateTargets(targets, file, graph);
93
137
 
94
138
  for (const target of resolvedTargets) {
@@ -110,35 +154,25 @@ function buildGraph(allSymbols) {
110
154
  }
111
155
  }
112
156
  }
113
-
114
- return graph;
115
157
  }
116
158
 
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
- }
159
+ function wireExplicitImportEdges(records, graph, fileLookup, addedImports) {
160
+ for (const { file, imports = [] } of records) {
161
+ for (const entry of imports) {
162
+ const targetFile = resolveImportTargetFile(file, entry.path, fileLookup);
163
+ if (!targetFile || targetFile === file) continue;
125
164
 
126
- // Re-add from newSymbols using the same logic as buildGraph,
127
- // but operating on the existing graph.
128
- const defIndex = new Map();
165
+ const impKey = `${file}\0${targetFile}`;
166
+ if (addedImports.has(impKey)) continue;
129
167
 
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);
168
+ addedImports.add(impKey);
169
+ graph.addEdge(file, targetFile, { type: 'IMPORTS' });
135
170
  }
136
- });
137
-
138
- const addedRefs = new Set();
139
- const addedImports = new Set();
171
+ }
172
+ }
140
173
 
141
- for (const { file, definitions, references } of newSymbols) {
174
+ function mergeDefinitions(graph, records) {
175
+ for (const { file, definitions } of records) {
142
176
  graph.mergeNode(file, { type: 'file', symbolCount: definitions.length });
143
177
 
144
178
  for (const def of definitions) {
@@ -156,36 +190,65 @@ function updateGraphFiles(graph, removedFiles, newSymbols) {
156
190
  localRefs: def.localRefs || null,
157
191
  });
158
192
  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
193
  }
194
+ }
195
+ }
163
196
 
164
- for (const ref of references) {
165
- const targets = defIndex.get(ref.name);
166
- if (!targets) continue;
197
+ function buildGraph(allSymbols) {
198
+ const graph = new MultiDirectedGraph({ allowSelfLoops: false });
199
+ mergeDefinitions(graph, allSymbols);
200
+ const defIndex = buildDefinitionIndex(graph);
167
201
 
168
- const resolvedTargets = disambiguateTargets(targets, file, graph);
202
+ // Dedup: one REFERENCES edge and one IMPORTS edge per unique (source, target) pair
203
+ const addedRefs = new Set();
204
+ const addedImports = new Set();
205
+ wireSymbolReferences(allSymbols, graph, defIndex, addedRefs, addedImports);
206
+ wireExplicitImportEdges(allSymbols, graph, buildFileLookup(graph), addedImports);
169
207
 
170
- for (const target of resolvedTargets) {
171
- const targetFile = graph.getNodeAttribute(target, 'file');
208
+ return graph;
209
+ }
172
210
 
173
- const refKey = `${file}\0${target}`;
174
- if (!addedRefs.has(refKey)) {
175
- addedRefs.add(refKey);
176
- graph.addEdge(file, target, { type: 'REFERENCES' });
177
- }
211
+ /**
212
+ * Incrementally update the graph: remove all nodes for the given files,
213
+ * then re-add from fresh parse results.
214
+ */
215
+ function updateGraphFiles(graph, removedFiles, newSymbols) {
216
+ const changedFiles = new Map(newSymbols.map(record => [record.file, record]));
178
217
 
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
- }
218
+ for (const filePath of removedFiles) {
219
+ const nextRecord = changedFiles.get(filePath);
220
+ if (!nextRecord) {
221
+ removeFileNodes(graph, filePath);
222
+ continue;
223
+ }
224
+
225
+ const outgoingEdges = [];
226
+ graph.forEachOutEdge(filePath, edge => {
227
+ outgoingEdges.push(edge);
228
+ });
229
+ for (const edge of outgoingEdges) {
230
+ graph.dropEdge(edge);
231
+ }
232
+
233
+ const nextNames = new Set(nextRecord.definitions.map(def => def.name));
234
+ const staleNodes = [];
235
+ graph.forEachNode((node, attrs) => {
236
+ if (attrs.type !== 'symbol' || attrs.file !== filePath) return;
237
+ if (!nextNames.has(attrs.name)) staleNodes.push(node);
238
+ });
239
+ for (const node of staleNodes) {
240
+ graph.dropNode(node);
187
241
  }
188
242
  }
243
+
244
+ mergeDefinitions(graph, newSymbols);
245
+
246
+ const defIndex = buildDefinitionIndex(graph);
247
+ const fileLookup = buildFileLookup(graph);
248
+ const addedRefs = new Set();
249
+ const addedImports = new Set();
250
+ wireSymbolReferences(newSymbols, graph, defIndex, addedRefs, addedImports);
251
+ wireExplicitImportEdges(newSymbols, graph, fileLookup, addedImports);
189
252
  }
190
253
 
191
254
  function removeFileNodes(graph, filePath) {
package/src/index.js CHANGED
@@ -578,6 +578,7 @@ 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>/.]|$)`);
581
582
  // Fallback: import/from lines that reference the symbol
582
583
  const importPattern = new RegExp(`(?:import|from)\\s.*\\b${escaped}\\b`);
583
584
 
@@ -604,7 +605,7 @@ class CodeIndex {
604
605
  if (inDef) continue;
605
606
 
606
607
  const line = lines[i];
607
- if (!callPattern.test(line) && !importPattern.test(line)) continue;
608
+ if (!callPattern.test(line) && !jsxPattern.test(line) && !importPattern.test(line)) continue;
608
609
 
609
610
  const start = Math.max(0, i - context);
610
611
  const end = Math.min(lines.length - 1, i + context);
package/src/outline.js CHANGED
@@ -25,7 +25,7 @@ export function buildOutline(source, filePath, expandSymbols = [], { callerCount
25
25
  return rawView(lines, pad);
26
26
  }
27
27
 
28
- const parsed = parseFile(filePath, source);
28
+ const parsed = parseFile(filePath, source, { includeOutlineDefinitions: true });
29
29
  if (!parsed || parsed.definitions.length === 0) {
30
30
  return rawView(lines, pad);
31
31
  }
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,6 +188,10 @@ const REF_QUERIES = {
178
188
  (call_expression function: (identifier) @ref)
179
189
  (import_specifier name: (identifier) @ref)
180
190
  (import_clause (identifier) @ref)
191
+ (jsx_opening_element (identifier) @jsx_ref)
192
+ (jsx_self_closing_element (identifier) @jsx_ref)
193
+ (jsx_opening_element (member_expression (identifier) @jsx_ref))
194
+ (jsx_self_closing_element (member_expression (identifier) @jsx_ref))
181
195
  `,
182
196
 
183
197
  typescript: `
@@ -187,6 +201,17 @@ const REF_QUERIES = {
187
201
  (type_identifier) @ref
188
202
  `,
189
203
 
204
+ tsx: `
205
+ (call_expression function: (identifier) @ref)
206
+ (import_specifier name: (identifier) @ref)
207
+ (import_clause (identifier) @ref)
208
+ (type_identifier) @ref
209
+ (jsx_opening_element (identifier) @jsx_ref)
210
+ (jsx_self_closing_element (identifier) @jsx_ref)
211
+ (jsx_opening_element (member_expression (identifier) @jsx_ref))
212
+ (jsx_self_closing_element (member_expression (identifier) @jsx_ref))
213
+ `,
214
+
190
215
  python: `
191
216
  (call function: (identifier) @ref)
192
217
  (decorator (identifier) @ref)
@@ -213,7 +238,21 @@ const REF_QUERIES = {
213
238
  `,
214
239
  };
215
240
 
216
- REF_QUERIES.tsx = REF_QUERIES.typescript;
241
+ const IMPORT_QUERIES = {
242
+ javascript: `
243
+ (import_statement source: (string) @import_path)
244
+ (export_statement source: (string) @import_path)
245
+ (call_expression function: (identifier) @require_fn arguments: (arguments (string) @import_path))
246
+ `,
247
+
248
+ typescript: `
249
+ (import_statement source: (string) @import_path)
250
+ (export_statement source: (string) @import_path)
251
+ (call_expression function: (identifier) @require_fn arguments: (arguments (string) @import_path))
252
+ `,
253
+ };
254
+
255
+ IMPORT_QUERIES.tsx = IMPORT_QUERIES.typescript;
217
256
 
218
257
  const KIND_MAP = {
219
258
  function_declaration: 'function',
@@ -244,8 +283,13 @@ const KIND_MAP = {
244
283
  variable_declarator: 'variable',
245
284
  namespace_definition: 'namespace',
246
285
  decorated_definition: 'function',
286
+ pair: 'property',
247
287
  };
248
288
 
289
+ const OUTLINE_EXTRA_LANGUAGES = new Set(['javascript', 'typescript', 'tsx']);
290
+ const OUTLINE_BODY_TYPES = new Set(['statement_block', 'class_body', 'object', 'array']);
291
+ const OUTLINE_CALL_TYPES = new Set(['call_expression', 'new_expression']);
292
+
249
293
  /**
250
294
  * Walk an AST subtree and count node types that reveal structural shape.
251
295
  * Returns a flat object like { if_statement: 3, for_statement: 1, call_expression: 7, ... }
@@ -343,22 +387,146 @@ function extractParamNames(node) {
343
387
  * Find the body/block node of a definition, drilling into wrappers like
344
388
  * lexical_declaration → variable_declarator → arrow_function → body.
345
389
  */
346
- function findBodyNode(node) {
347
- let body = node.childForFieldName('body');
348
- if (body) return body;
390
+ function findCallArgumentBody(node, depth) {
391
+ if (!node) return null;
392
+ let fallback = null;
349
393
 
350
394
  for (let i = 0; i < node.namedChildCount; i++) {
351
395
  const child = node.namedChild(i);
352
- body = child.childForFieldName('body');
353
- if (body) return body;
354
- for (let j = 0; j < child.namedChildCount; j++) {
355
- body = child.namedChild(j).childForFieldName('body');
356
- if (body) return body;
396
+ const body = findBodyNode(child, depth + 1);
397
+ if (!body) continue;
398
+ if (body.type === 'statement_block' || body.type === 'class_body') {
399
+ return body;
357
400
  }
401
+ if (!fallback) fallback = body;
402
+ }
403
+
404
+ return fallback;
405
+ }
406
+
407
+ function findBodyNode(node, depth = 0) {
408
+ if (!node || depth > 12) return null;
409
+ if (OUTLINE_BODY_TYPES.has(node.type)) return node;
410
+
411
+ const body = node.childForFieldName('body');
412
+ if (body) {
413
+ const nestedBody = findBodyNode(body, depth + 1);
414
+ return nestedBody || body;
415
+ }
416
+
417
+ if (OUTLINE_CALL_TYPES.has(node.type)) {
418
+ const argBody = findCallArgumentBody(node.childForFieldName('arguments'), depth);
419
+ if (argBody) return argBody;
358
420
  }
421
+
422
+ for (const fieldName of ['value', 'expression', 'argument']) {
423
+ const child = node.childForFieldName(fieldName);
424
+ if (!child) continue;
425
+ const nestedBody = findBodyNode(child, depth + 1);
426
+ if (nestedBody) return nestedBody;
427
+ }
428
+
429
+ for (let i = 0; i < node.namedChildCount; i++) {
430
+ const nestedBody = findBodyNode(node.namedChild(i), depth + 1);
431
+ if (nestedBody) return nestedBody;
432
+ }
433
+
359
434
  return null;
360
435
  }
361
436
 
437
+ function computeBodyStartLine(node, bodyNode) {
438
+ if (!bodyNode) return null;
439
+
440
+ const bodyRow = bodyNode.startPosition.row;
441
+ const defRow = node.startPosition.row;
442
+ return bodyRow === defRow ? bodyRow + 2 : bodyRow + 1;
443
+ }
444
+
445
+ function createDefinition(name, node, filePath, langName, { bodyStartLine = null, kind } = {}) {
446
+ const bodyNode = bodyStartLine == null ? findBodyNode(node) : null;
447
+ const resolvedBodyStartLine = bodyStartLine ?? computeBodyStartLine(node, bodyNode);
448
+ const profileNode = bodyNode || node;
449
+
450
+ return {
451
+ name,
452
+ kind: kind || nodeKind(node.type),
453
+ file: filePath,
454
+ lineStart: node.startPosition.row + 1,
455
+ lineEnd: node.endPosition.row + 1,
456
+ signature: extractSignature(node, langName),
457
+ bodyStartLine: resolvedBodyStartLine,
458
+ astProfile: buildAstProfile(profileNode),
459
+ paramNames: extractParamNames(node),
460
+ };
461
+ }
462
+
463
+ function extractPairName(node) {
464
+ const keyNode = node.childForFieldName('key') || node.namedChild(0);
465
+ if (!keyNode) return null;
466
+
467
+ if (['property_identifier', 'identifier', 'private_property_identifier', 'number'].includes(keyNode.type)) {
468
+ return keyNode.text;
469
+ }
470
+
471
+ if (keyNode.type === 'string') {
472
+ return keyNode.text.replace(/^['"`]|['"`]$/g, '');
473
+ }
474
+
475
+ return null;
476
+ }
477
+
478
+ function hasMultilinePairChildren(node) {
479
+ if (!node || node.type !== 'object') return false;
480
+
481
+ for (let i = 0; i < node.namedChildCount; i++) {
482
+ const child = node.namedChild(i);
483
+ if (child.type !== 'pair') continue;
484
+ if (!extractPairName(child)) continue;
485
+ if (child.endPosition.row > child.startPosition.row) return true;
486
+ }
487
+
488
+ return false;
489
+ }
490
+
491
+ function collectOutlineDefinitions(rootNode, filePath, langName) {
492
+ if (!OUTLINE_EXTRA_LANGUAGES.has(langName)) return [];
493
+
494
+ const definitions = [];
495
+
496
+ function visit(node) {
497
+ if (node.type === 'pair') {
498
+ const name = extractPairName(node);
499
+ if (name && node.endPosition.row > node.startPosition.row) {
500
+ definitions.push(createDefinition(name, node, filePath, langName, {
501
+ bodyStartLine: node.startPosition.row + 2,
502
+ kind: 'property',
503
+ }));
504
+ }
505
+ } else if (node.type === 'variable_declarator') {
506
+ const nameNode = node.childForFieldName('name');
507
+ const bodyNode = findBodyNode(node);
508
+ if (
509
+ nameNode &&
510
+ nameNode.type === 'identifier' &&
511
+ node.endPosition.row > node.startPosition.row &&
512
+ !(bodyNode && bodyNode.type === 'object' && hasMultilinePairChildren(bodyNode))
513
+ ) {
514
+ definitions.push(createDefinition(nameNode.text, node, filePath, langName, {
515
+ bodyStartLine: node.startPosition.row + 2,
516
+ kind: 'variable',
517
+ }));
518
+ }
519
+ }
520
+
521
+ for (let i = 0; i < node.namedChildCount; i++) {
522
+ visit(node.namedChild(i));
523
+ }
524
+ }
525
+
526
+ visit(rootNode);
527
+ return definitions;
528
+ }
529
+
362
530
  function nodeKind(nodeType) {
363
531
  return KIND_MAP[nodeType] || 'other';
364
532
  }
@@ -393,11 +561,31 @@ function extractSignature(node, langName) {
393
561
  return sig.length > 200 ? sig.substring(0, 200) + '...' : sig;
394
562
  }
395
563
 
564
+ function normalizeReferenceCapture(capture) {
565
+ if (!capture) return null;
566
+ const text = capture.node.text;
567
+ if (!text) return null;
568
+
569
+ if (capture.name === 'jsx_ref') {
570
+ return /^[A-Z]/.test(text) ? text : null;
571
+ }
572
+
573
+ return text;
574
+ }
575
+
576
+ function normalizeImportPathCapture(capture) {
577
+ if (!capture) return null;
578
+ const text = capture.node.text;
579
+ if (!text) return null;
580
+
581
+ return text.replace(/^['"`]|['"`]$/g, '');
582
+ }
583
+
396
584
  /**
397
- * Parse a single source file and extract definitions + references.
585
+ * Parse a single source file and extract definitions, references, and imports.
398
586
  * Returns null if the language is unsupported.
399
587
  */
400
- function parseFile(filePath, source) {
588
+ function parseFile(filePath, source, { includeOutlineDefinitions = false } = {}) {
401
589
  const dotIdx = filePath.lastIndexOf('.');
402
590
  if (dotIdx === -1) return null;
403
591
  const ext = filePath.substring(dotIdx);
@@ -411,6 +599,7 @@ function parseFile(filePath, source) {
411
599
 
412
600
  const definitions = [];
413
601
  const references = [];
602
+ const imports = [];
414
603
 
415
604
  const defQueryStr = DEF_QUERIES[langName] || null;
416
605
  if (defQueryStr) {
@@ -422,50 +611,54 @@ function parseFile(filePath, source) {
422
611
  if (!nameCapture) continue;
423
612
  const defNode = defCapture || nameCapture;
424
613
 
425
- // Compute where body content starts (for outline collapsing)
426
- const bodyNode = findBodyNode(defNode.node);
427
- let bodyStartLine = null;
428
- if (bodyNode) {
429
- const bodyRow = bodyNode.startPosition.row; // 0-indexed
430
- const defRow = defNode.node.startPosition.row; // 0-indexed
431
- // If body opens on same line as def (JS: `function foo() {`),
432
- // content starts on next line. Otherwise body IS the content.
433
- bodyStartLine = bodyRow === defRow ? bodyRow + 2 : bodyRow + 1; // 1-indexed
434
- }
435
-
436
- // Build AST profile from function body (or whole node if no body)
437
- const profileNode = bodyNode || defNode.node;
438
- const astProfile = buildAstProfile(profileNode);
439
- const paramNames = extractParamNames(defNode.node);
440
-
441
- definitions.push({
442
- name: nameCapture.node.text,
443
- kind: nodeKind(defNode.node.type),
444
- file: filePath,
445
- lineStart: defNode.node.startPosition.row + 1,
446
- lineEnd: defNode.node.endPosition.row + 1,
447
- signature: extractSignature(defNode.node, langName),
448
- bodyStartLine,
449
- astProfile,
450
- paramNames,
451
- });
614
+ definitions.push(createDefinition(nameCapture.node.text, defNode.node, filePath, langName));
452
615
  }
453
616
  } catch (e) {
454
617
  // Query may fail on some grammar versions; degrade gracefully
455
618
  }
456
619
  }
457
620
 
621
+ if (includeOutlineDefinitions) {
622
+ definitions.push(...collectOutlineDefinitions(tree.rootNode, filePath, langName));
623
+ }
624
+
458
625
  const refQueryStr = REF_QUERIES[langName] || REF_QUERIES.default;
459
626
  if (refQueryStr) {
460
627
  try {
461
628
  const refQuery = new Parser.Query(lang, refQueryStr);
462
629
  for (const match of refQuery.matches(tree.rootNode)) {
463
- const refCapture = match.captures.find(c => c.name === 'ref');
464
- if (!refCapture) continue;
465
- references.push({
466
- name: refCapture.node.text,
630
+ for (const capture of match.captures) {
631
+ if (capture.name !== 'ref' && capture.name !== 'jsx_ref') continue;
632
+ const name = normalizeReferenceCapture(capture);
633
+ if (!name) continue;
634
+ references.push({
635
+ name,
636
+ file: filePath,
637
+ line: capture.node.startPosition.row + 1,
638
+ });
639
+ }
640
+ }
641
+ } catch (e) {
642
+ // Degrade gracefully
643
+ }
644
+ }
645
+
646
+ const importQueryStr = IMPORT_QUERIES[langName] || null;
647
+ if (importQueryStr) {
648
+ try {
649
+ const importQuery = new Parser.Query(lang, importQueryStr);
650
+ for (const match of importQuery.matches(tree.rootNode)) {
651
+ const requireCapture = match.captures.find(c => c.name === 'require_fn');
652
+ if (requireCapture && requireCapture.node.text !== 'require') continue;
653
+
654
+ const pathCapture = match.captures.find(c => c.name === 'import_path');
655
+ const path = normalizeImportPathCapture(pathCapture);
656
+ if (!path) continue;
657
+
658
+ imports.push({
659
+ path,
467
660
  file: filePath,
468
- line: refCapture.node.startPosition.row + 1,
661
+ line: pathCapture.node.startPosition.row + 1,
469
662
  });
470
663
  }
471
664
  } catch (e) {
@@ -494,7 +687,7 @@ function parseFile(filePath, source) {
494
687
 
495
688
  // No tree.delete()/parser.delete() needed — native GC handles cleanup
496
689
 
497
- return { file: filePath, definitions, references };
690
+ return { file: filePath, definitions, references, imports };
498
691
  }
499
692
 
500
693
  export { parseFile, buildAstProfile, extractParamNames, SUPPORTED_EXTENSIONS, LANG_MAP };