@mishasinitcyn/betterrank 0.2.7 → 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.7",
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/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',
@@ -522,8 +561,28 @@ function extractSignature(node, langName) {
522
561
  return sig.length > 200 ? sig.substring(0, 200) + '...' : sig;
523
562
  }
524
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
+
525
584
  /**
526
- * Parse a single source file and extract definitions + references.
585
+ * Parse a single source file and extract definitions, references, and imports.
527
586
  * Returns null if the language is unsupported.
528
587
  */
529
588
  function parseFile(filePath, source, { includeOutlineDefinitions = false } = {}) {
@@ -540,6 +599,7 @@ function parseFile(filePath, source, { includeOutlineDefinitions = false } = {})
540
599
 
541
600
  const definitions = [];
542
601
  const references = [];
602
+ const imports = [];
543
603
 
544
604
  const defQueryStr = DEF_QUERIES[langName] || null;
545
605
  if (defQueryStr) {
@@ -567,12 +627,38 @@ function parseFile(filePath, source, { includeOutlineDefinitions = false } = {})
567
627
  try {
568
628
  const refQuery = new Parser.Query(lang, refQueryStr);
569
629
  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,
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,
574
660
  file: filePath,
575
- line: refCapture.node.startPosition.row + 1,
661
+ line: pathCapture.node.startPosition.row + 1,
576
662
  });
577
663
  }
578
664
  } catch (e) {
@@ -601,7 +687,7 @@ function parseFile(filePath, source, { includeOutlineDefinitions = false } = {})
601
687
 
602
688
  // No tree.delete()/parser.delete() needed — native GC handles cleanup
603
689
 
604
- return { file: filePath, definitions, references };
690
+ return { file: filePath, definitions, references, imports };
605
691
  }
606
692
 
607
693
  export { parseFile, buildAstProfile, extractParamNames, SUPPORTED_EXTENSIONS, LANG_MAP };