@mishasinitcyn/betterrank 0.1.8 → 0.1.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.1.8",
3
+ "version": "0.1.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/cli.js CHANGED
@@ -19,6 +19,7 @@ Commands:
19
19
  deps <file> What this file imports (ranked)
20
20
  dependents <file> What imports this file (ranked)
21
21
  neighborhood <file> [--hops N] [--max-files N] Local subgraph (ranked by PageRank)
22
+ orphans [--level file|symbol] [--kind type] Find disconnected files/symbols
22
23
  reindex Force full rebuild
23
24
  stats Index statistics
24
25
 
@@ -146,6 +147,30 @@ Examples:
146
147
  betterrank neighborhood src/auth/handlers.ts --root ./backend
147
148
  betterrank neighborhood src/api/bid.js --hops 3 --max-files 20 --root .`,
148
149
 
150
+ orphans: `betterrank orphans [--level file|symbol] [--kind type] [--root <path>]
151
+
152
+ Find disconnected files or symbols — the "satellites" in the graph UI.
153
+
154
+ Levels:
155
+ file Files with zero cross-file imports (default)
156
+ symbol Symbols never referenced from outside their own file (dead code candidates)
157
+
158
+ Options:
159
+ --level <type> "file" or "symbol" (default: file)
160
+ --kind <type> Filter symbols: function, class, type, variable (only with --level symbol)
161
+ --count Return count only
162
+ --offset N Skip first N results
163
+ --limit N Max results (default: ${DEFAULT_LIMIT})
164
+
165
+ False positives (entry points, config files, tests, framework hooks, dunders,
166
+ etc.) are automatically excluded.
167
+
168
+ Examples:
169
+ betterrank orphans --root ./backend
170
+ betterrank orphans --level symbol --root .
171
+ betterrank orphans --level symbol --kind function --root .
172
+ betterrank orphans --count --root .`,
173
+
149
174
  reindex: `betterrank reindex [--root <path>]
150
175
 
151
176
  Force a full rebuild of the index. Use after branch switches, large merges,
@@ -465,6 +490,55 @@ async function main() {
465
490
  break;
466
491
  }
467
492
 
493
+ case 'orphans': {
494
+ const level = flags.level || 'file';
495
+ if (level !== 'file' && level !== 'symbol') {
496
+ console.error(`Unknown level: "${level}". Use "file" or "symbol".`);
497
+ process.exit(1);
498
+ }
499
+ const effectiveLimit = countMode ? undefined : (userLimit !== undefined ? userLimit : DEFAULT_LIMIT);
500
+ const result = await idx.orphans({ level, kind: flags.kind, count: countMode, offset, limit: effectiveLimit });
501
+
502
+ if (countMode) {
503
+ console.log(`total: ${result.total}`);
504
+ } else if (level === 'file') {
505
+ for (const f of result) {
506
+ console.log(`${f.file} (${f.symbolCount} symbols)`);
507
+ }
508
+ if (result.length === 0) {
509
+ console.log('(no orphan files found)');
510
+ } else {
511
+ const total = await idx.orphans({ level, count: true });
512
+ if (result.length < total.total) {
513
+ console.log(`\nShowing ${result.length} of ${total.total} orphan files (use --limit N for more)`);
514
+ }
515
+ }
516
+ } else {
517
+ // symbol level — group by file like map output
518
+ const byFile = new Map();
519
+ for (const s of result) {
520
+ if (!byFile.has(s.file)) byFile.set(s.file, []);
521
+ byFile.get(s.file).push(s);
522
+ }
523
+ for (const [file, syms] of byFile) {
524
+ console.log(`${file}:`);
525
+ for (const s of syms) {
526
+ console.log(` ${String(s.lineStart).padStart(4)}│ [${s.kind}] ${s.signature}`);
527
+ }
528
+ console.log('');
529
+ }
530
+ if (result.length === 0) {
531
+ console.log('(no orphan symbols found)');
532
+ } else {
533
+ const total = await idx.orphans({ level, kind: flags.kind, count: true });
534
+ if (result.length < total.total) {
535
+ console.log(`Showing ${result.length} of ${total.total} orphan symbols across ${byFile.size} files (use --limit N for more)`);
536
+ }
537
+ }
538
+ }
539
+ break;
540
+ }
541
+
468
542
  case 'reindex': {
469
543
  const t0 = Date.now();
470
544
  const result = await idx.reindex();
package/src/index.js CHANGED
@@ -3,6 +3,132 @@ import { join, dirname, relative, sep, basename } from 'path';
3
3
  import { CodeIndexCache } from './cache.js';
4
4
  import { rankedSymbols } from './graph.js';
5
5
 
6
+ // ── Orphan false-positive filters ──────────────────────────────────────────
7
+ //
8
+ // Orphan detection finds files/symbols with no cross-file connections.
9
+ // Many of these are false positives: entry points, config, tests, framework
10
+ // hooks, etc. that are invoked by runtimes, not by other source files.
11
+ // These filters aggressively exclude them (at the cost of some true positives).
12
+
13
+ // File basenames (without extension) that are runtime entry points, config,
14
+ // or package markers — they have no incoming IMPORTS because the runtime
15
+ // loads them directly, not because they're dead.
16
+ const ORPHAN_EXCLUDED_BASENAMES = new Set([
17
+ 'index', 'main', 'app', 'server', 'cli', 'mod', 'lib',
18
+ 'manage', 'wsgi', 'asgi', 'handler', 'lambda',
19
+ '__init__', '__main__',
20
+ 'config', 'settings', 'conf', 'conftest', 'setup',
21
+ 'gulpfile', 'gruntfile', 'makefile', 'rakefile', 'taskfile',
22
+ ]);
23
+
24
+ // Path segments indicating test/spec directories
25
+ const TEST_PATH_SEGMENTS = [
26
+ '/test/', '/tests/', '/__tests__/', '/spec/', '/specs/',
27
+ '/testing/', '/fixtures/', '/mocks/', '/e2e/', '/cypress/',
28
+ ];
29
+
30
+ function isTestFile(filePath) {
31
+ const lower = '/' + filePath.toLowerCase();
32
+ for (const seg of TEST_PATH_SEGMENTS) {
33
+ if (lower.includes(seg)) return true;
34
+ }
35
+ const stem = basename(filePath).replace(/\.[^.]+$/, '').toLowerCase();
36
+ return (
37
+ stem.startsWith('test_') || stem.startsWith('test.') ||
38
+ stem.endsWith('.test') || stem.endsWith('.spec') ||
39
+ stem.endsWith('_test') || stem.endsWith('_spec')
40
+ );
41
+ }
42
+
43
+ function isOrphanFalsePositiveFile(filePath) {
44
+ const base = basename(filePath);
45
+ const stem = base.replace(/\.[^.]+$/, '').toLowerCase();
46
+
47
+ if (ORPHAN_EXCLUDED_BASENAMES.has(stem)) return true;
48
+
49
+ // Dotfiles are always config (.eslintrc, .prettierrc, etc.)
50
+ if (base.startsWith('.')) return true;
51
+
52
+ // Type definition files (.d.ts) — consumed by the compiler, not by imports
53
+ if (filePath.endsWith('.d.ts')) return true;
54
+
55
+ // Config files with compound names (vite.config.ts, jest.config.js, etc.)
56
+ if (/[./]config$/i.test(stem) || /\.rc$/i.test(stem)) return true;
57
+
58
+ // Test/spec files — invoked by test runners
59
+ if (isTestFile(filePath)) return true;
60
+
61
+ return false;
62
+ }
63
+
64
+ // Symbol names that are entry points, lifecycle hooks, or framework-called.
65
+ const FRAMEWORK_INVOKED_SYMBOLS = new Set([
66
+ 'main', 'run', 'start', 'serve', 'handler', 'execute', 'app',
67
+ 'setup', 'teardown', 'setUp', 'tearDown',
68
+ 'beforeAll', 'afterAll', 'beforeEach', 'afterEach', 'before', 'after',
69
+ 'constructor', 'init', 'initialize', 'configure', 'register',
70
+ 'middleware', 'plugin', 'default', 'module', 'exports',
71
+ ]);
72
+
73
+ /**
74
+ * Detect if a function signature is likely a class/instance method rather
75
+ * than a standalone function. Method calls (obj.method()) are intentionally
76
+ * not tracked as references (too noisy without type info), so all methods
77
+ * appear orphaned. We exclude them to avoid flooding the results.
78
+ */
79
+ function isLikelyMethod(signature, filePath) {
80
+ if (!signature) return false;
81
+ const s = signature.trimStart();
82
+
83
+ const ext = filePath.substring(filePath.lastIndexOf('.'));
84
+
85
+ // JS/TS: standalone functions always use the `function` keyword.
86
+ // Class methods don't: `async ensure()`, `getGraph()`, `constructor()`.
87
+ // Arrow functions assigned to vars are kind='variable', not 'function',
88
+ // so they don't reach this check.
89
+ if (['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx'].includes(ext)) {
90
+ return !/^(export\s+)?(default\s+)?(async\s+)?function[\s(]/.test(s);
91
+ }
92
+
93
+ // Python: methods have self or cls as first parameter
94
+ if (ext === '.py') {
95
+ return /\(\s*(self|cls)\s*[,)]/.test(s);
96
+ }
97
+
98
+ // Java/C#/Go: harder to detect without parent context — don't filter
99
+ return false;
100
+ }
101
+
102
+ function isOrphanFalsePositiveSymbol(name, kind, filePath, signature) {
103
+ if (FRAMEWORK_INVOKED_SYMBOLS.has(name)) return true;
104
+
105
+ // Python dunders — called implicitly by the runtime
106
+ if (name.startsWith('__') && name.endsWith('__')) return true;
107
+
108
+ // Test functions — called by test runners
109
+ if (name.startsWith('test_') || name.startsWith('Test') ||
110
+ name.startsWith('spec_') || name.startsWith('Spec')) return true;
111
+
112
+ // Very short names — too generic, ambiguity cap probably suppressed real refs
113
+ if (name.length <= 2) return true;
114
+
115
+ // Class/instance methods — obj.method() calls aren't tracked as references,
116
+ // so every method appears orphaned. Filter them out.
117
+ if (kind === 'function' && isLikelyMethod(signature, filePath)) return true;
118
+
119
+ // Symbols in test files — all invoked by the test runner
120
+ if (isTestFile(filePath)) return true;
121
+
122
+ // Symbols in entry point / config files — reachable via runtime
123
+ if (isOrphanFalsePositiveFile(filePath)) return true;
124
+
125
+ // Symbol name matches file basename — likely the primary export
126
+ const fileBase = basename(filePath).replace(/\.[^.]+$/, '');
127
+ if (name === fileBase || name.toLowerCase() === fileBase.toLowerCase()) return true;
128
+
129
+ return false;
130
+ }
131
+
6
132
  /**
7
133
  * Find file nodes in the graph that look similar to the given path.
8
134
  * Uses basename matching and substring matching on the full path.
@@ -635,6 +761,98 @@ class CodeIndex {
635
761
  };
636
762
  }
637
763
 
764
+ /**
765
+ * Find orphaned files or symbols — nodes with no cross-file connections.
766
+ *
767
+ * level='file': files with zero IMPORTS edges (neither importing nor imported).
768
+ * These are the "satellites" in the graph UI.
769
+ *
770
+ * level='symbol': symbols with no incoming REFERENCES from outside their own file.
771
+ * Dead code candidates — defined but never used cross-file.
772
+ *
773
+ * False positives (entry points, config files, test files, framework hooks,
774
+ * dunders, etc.) are excluded by default.
775
+ *
776
+ * @param {object} [opts]
777
+ * @param {'file'|'symbol'} [opts.level='file'] - Granularity
778
+ * @param {string} [opts.kind] - Filter symbols by kind (only for level='symbol')
779
+ * @param {number} [opts.offset] - Skip first N results
780
+ * @param {number} [opts.limit] - Max results to return
781
+ * @param {boolean} [opts.count=false] - If true, return only { total }
782
+ * @returns {Array|{total: number}}
783
+ */
784
+ async orphans({ level = 'file', kind, offset, limit, count = false } = {}) {
785
+ await this._ensureReady();
786
+ const graph = this.cache.getGraph();
787
+ if (!graph || graph.order === 0) return count ? { total: 0 } : [];
788
+
789
+ if (level === 'file') {
790
+ const results = [];
791
+ graph.forEachNode((node, attrs) => {
792
+ if (attrs.type !== 'file') return;
793
+
794
+ // Skip false positives: entry points, config, tests
795
+ if (isOrphanFalsePositiveFile(node)) return;
796
+
797
+ // Check for any IMPORTS edge (in or out)
798
+ let hasImport = false;
799
+ graph.forEachEdge(node, (_edge, edgeAttrs) => {
800
+ if (!hasImport && edgeAttrs.type === 'IMPORTS') hasImport = true;
801
+ });
802
+
803
+ if (!hasImport) {
804
+ results.push({ file: node, symbolCount: attrs.symbolCount || 0 });
805
+ }
806
+ });
807
+
808
+ // Meatier files first — more likely to be real orphans worth investigating
809
+ results.sort((a, b) => b.symbolCount - a.symbolCount);
810
+ if (count) return { total: results.length };
811
+ return paginate(results, { offset, limit }).items;
812
+ }
813
+
814
+ if (level === 'symbol') {
815
+ const results = [];
816
+ graph.forEachNode((node, attrs) => {
817
+ if (attrs.type !== 'symbol') return;
818
+ if (kind && attrs.kind !== kind) return;
819
+
820
+ // Skip false positives: framework hooks, dunders, test funcs, methods, etc.
821
+ if (isOrphanFalsePositiveSymbol(attrs.name, attrs.kind, attrs.file, attrs.signature)) return;
822
+
823
+ // Check for any incoming REFERENCES from a different file
824
+ let hasExternalRef = false;
825
+ graph.forEachInEdge(node, (_edge, edgeAttrs, source) => {
826
+ if (hasExternalRef) return;
827
+ if (edgeAttrs.type !== 'REFERENCES') return;
828
+ try {
829
+ const sourceFile = graph.getNodeAttribute(source, 'file') || source;
830
+ if (sourceFile !== attrs.file) hasExternalRef = true;
831
+ } catch {
832
+ if (source !== attrs.file) hasExternalRef = true;
833
+ }
834
+ });
835
+
836
+ if (!hasExternalRef) {
837
+ results.push({
838
+ name: attrs.name,
839
+ kind: attrs.kind,
840
+ file: attrs.file,
841
+ lineStart: attrs.lineStart,
842
+ signature: attrs.signature,
843
+ });
844
+ }
845
+ });
846
+
847
+ // Group by file, then by line within file
848
+ results.sort((a, b) => a.file.localeCompare(b.file) || a.lineStart - b.lineStart);
849
+ if (count) return { total: results.length };
850
+ return paginate(results, { offset, limit }).items;
851
+ }
852
+
853
+ throw new Error(`Unknown level: "${level}". Use "file" or "symbol".`);
854
+ }
855
+
638
856
  /**
639
857
  * File-level dependency graph for visualization.
640
858
  * Returns nodes (files) ranked by PageRank and IMPORTS edges between them.
package/src/server.js CHANGED
@@ -257,6 +257,24 @@ const routes = {
257
257
  json(res, result);
258
258
  },
259
259
 
260
+ 'GET /api/orphans': async (req, res) => {
261
+ if (!requireIndex(res)) return;
262
+ const p = params(req.url);
263
+ const level = p.get('level', 'file');
264
+ const results = await currentIndex.orphans({
265
+ level,
266
+ kind: p.get('kind', undefined),
267
+ offset: p.getInt('offset', undefined),
268
+ limit: p.getInt('limit', 50),
269
+ });
270
+ const total = await currentIndex.orphans({
271
+ level,
272
+ kind: p.get('kind', undefined),
273
+ count: true,
274
+ });
275
+ json(res, { results, total: total.total });
276
+ },
277
+
260
278
  'GET /api/structure': async (req, res) => {
261
279
  if (!requireIndex(res)) return;
262
280
  const p = params(req.url);