@mishasinitcyn/betterrank 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/package.json +1 -1
- package/src/cli.js +124 -0
- package/src/index.js +218 -0
- package/src/outline.js +139 -0
- package/src/parser.js +32 -0
- package/src/server.js +18 -0
package/README.md
CHANGED
|
@@ -16,6 +16,12 @@ npm install -g @mishasinitcyn/betterrank
|
|
|
16
16
|
# Get a ranked overview of any project
|
|
17
17
|
betterrank map --root /path/to/project
|
|
18
18
|
|
|
19
|
+
# View a file's skeleton without reading the whole thing
|
|
20
|
+
betterrank outline src/auth.py
|
|
21
|
+
|
|
22
|
+
# Expand a specific function to see its full source
|
|
23
|
+
betterrank outline src/auth.py authenticate_user
|
|
24
|
+
|
|
19
25
|
# Search for symbols by name or parameter
|
|
20
26
|
betterrank search auth --root /path/to/project
|
|
21
27
|
|
|
@@ -45,6 +51,42 @@ JavaScript, TypeScript, Python, Rust, Go, Java, Ruby, C, C++, C#, PHP
|
|
|
45
51
|
|
|
46
52
|
## Commands
|
|
47
53
|
|
|
54
|
+
### `outline` — File skeleton with collapsed bodies
|
|
55
|
+
|
|
56
|
+
View a file's structure without reading the entire thing. Function and class bodies are collapsed to `... (N lines)`. Expand specific symbols by name. **No `--root` required** — works on any file standalone.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# Skeleton view: imports, signatures, constants — bodies collapsed
|
|
60
|
+
betterrank outline src/auth.py
|
|
61
|
+
|
|
62
|
+
# Expand a specific function to see its full source
|
|
63
|
+
betterrank outline src/auth.py authenticate_user
|
|
64
|
+
|
|
65
|
+
# Expand multiple symbols
|
|
66
|
+
betterrank outline src/auth.py validate,process
|
|
67
|
+
|
|
68
|
+
# Resolve path relative to a root
|
|
69
|
+
betterrank outline src/auth.py --root ./backend
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Example output:**
|
|
73
|
+
```
|
|
74
|
+
1│ from fastapi import APIRouter, Depends
|
|
75
|
+
2│ from core.auth import verify_auth
|
|
76
|
+
3│
|
|
77
|
+
4│ router = APIRouter(prefix="/api")
|
|
78
|
+
5│
|
|
79
|
+
6│ @router.get("/users")
|
|
80
|
+
7│ async def list_users(db = Depends(get_db)):
|
|
81
|
+
│ ... (25 lines)
|
|
82
|
+
33│
|
|
83
|
+
34│ @router.post("/users")
|
|
84
|
+
35│ async def create_user(data: UserCreate, db = Depends(get_db)):
|
|
85
|
+
│ ... (40 lines)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Typical compression: **3-5x** (a 2000-line file becomes ~400 lines of outline).
|
|
89
|
+
|
|
48
90
|
### `map` — Repo map
|
|
49
91
|
|
|
50
92
|
Summary of the most structurally important definitions, ranked by PageRank. Default limit is 50 symbols.
|
|
@@ -99,6 +141,14 @@ betterrank neighborhood src/auth.ts --root /path/to/project --count
|
|
|
99
141
|
betterrank neighborhood src/auth.ts --root /path/to/project --hops 2 --limit 10
|
|
100
142
|
```
|
|
101
143
|
|
|
144
|
+
### `orphans` — Find disconnected files/symbols
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
betterrank orphans --root /path/to/project # orphan files
|
|
148
|
+
betterrank orphans --level symbol --root /path/to/project # orphan symbols
|
|
149
|
+
betterrank orphans --level symbol --kind function --root /path/to/project
|
|
150
|
+
```
|
|
151
|
+
|
|
102
152
|
### `structure` — File tree with symbol counts
|
|
103
153
|
|
|
104
154
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mishasinitcyn/betterrank",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { CodeIndex } from './index.js';
|
|
4
4
|
import { resolve, relative, isAbsolute } from 'path';
|
|
5
|
+
import { readFile } from 'fs/promises';
|
|
5
6
|
|
|
6
7
|
const DEFAULT_LIMIT = 50;
|
|
7
8
|
const DEFAULT_DEPTH = 3;
|
|
@@ -11,6 +12,7 @@ betterrank <command> [options]
|
|
|
11
12
|
|
|
12
13
|
Commands:
|
|
13
14
|
ui [--port N] Launch web UI (default port: 3333)
|
|
15
|
+
outline <file> [symbol1,symbol2] File skeleton with collapsed bodies
|
|
14
16
|
map [--focus file1,file2] Repo map (ranked by PageRank)
|
|
15
17
|
search <query> [--kind type] Substring search on symbol names + signatures (ranked by PageRank)
|
|
16
18
|
structure [--depth N] File tree with symbol counts (default depth: ${DEFAULT_DEPTH})
|
|
@@ -19,6 +21,7 @@ Commands:
|
|
|
19
21
|
deps <file> What this file imports (ranked)
|
|
20
22
|
dependents <file> What imports this file (ranked)
|
|
21
23
|
neighborhood <file> [--hops N] [--max-files N] Local subgraph (ranked by PageRank)
|
|
24
|
+
orphans [--level file|symbol] [--kind type] Find disconnected files/symbols
|
|
22
25
|
reindex Force full rebuild
|
|
23
26
|
stats Index statistics
|
|
24
27
|
|
|
@@ -31,6 +34,26 @@ Global flags:
|
|
|
31
34
|
`.trim();
|
|
32
35
|
|
|
33
36
|
const COMMAND_HELP = {
|
|
37
|
+
outline: `betterrank outline <file> [symbol1,symbol2,...] [--root <path>]
|
|
38
|
+
|
|
39
|
+
View a file's structure with function/class bodies collapsed, or expand
|
|
40
|
+
specific symbols to see their full source.
|
|
41
|
+
|
|
42
|
+
Without symbol names: shows the file skeleton — imports, constants, and
|
|
43
|
+
function/class signatures with bodies replaced by "... (N lines)".
|
|
44
|
+
|
|
45
|
+
With symbol names (comma-separated): shows the full source of those
|
|
46
|
+
specific functions/classes with line numbers.
|
|
47
|
+
|
|
48
|
+
Options:
|
|
49
|
+
--root <path> Resolve file path relative to this directory
|
|
50
|
+
|
|
51
|
+
Examples:
|
|
52
|
+
betterrank outline src/auth.py
|
|
53
|
+
betterrank outline src/auth.py authenticate_user
|
|
54
|
+
betterrank outline src/auth.py validate,process
|
|
55
|
+
betterrank outline src/handlers.ts --root ./backend`,
|
|
56
|
+
|
|
34
57
|
map: `betterrank map [--focus file1,file2] [--root <path>]
|
|
35
58
|
|
|
36
59
|
Aider-style repo map: the most structurally important definitions ranked by PageRank.
|
|
@@ -146,6 +169,30 @@ Examples:
|
|
|
146
169
|
betterrank neighborhood src/auth/handlers.ts --root ./backend
|
|
147
170
|
betterrank neighborhood src/api/bid.js --hops 3 --max-files 20 --root .`,
|
|
148
171
|
|
|
172
|
+
orphans: `betterrank orphans [--level file|symbol] [--kind type] [--root <path>]
|
|
173
|
+
|
|
174
|
+
Find disconnected files or symbols — the "satellites" in the graph UI.
|
|
175
|
+
|
|
176
|
+
Levels:
|
|
177
|
+
file Files with zero cross-file imports (default)
|
|
178
|
+
symbol Symbols never referenced from outside their own file (dead code candidates)
|
|
179
|
+
|
|
180
|
+
Options:
|
|
181
|
+
--level <type> "file" or "symbol" (default: file)
|
|
182
|
+
--kind <type> Filter symbols: function, class, type, variable (only with --level symbol)
|
|
183
|
+
--count Return count only
|
|
184
|
+
--offset N Skip first N results
|
|
185
|
+
--limit N Max results (default: ${DEFAULT_LIMIT})
|
|
186
|
+
|
|
187
|
+
False positives (entry points, config files, tests, framework hooks, dunders,
|
|
188
|
+
etc.) are automatically excluded.
|
|
189
|
+
|
|
190
|
+
Examples:
|
|
191
|
+
betterrank orphans --root ./backend
|
|
192
|
+
betterrank orphans --level symbol --root .
|
|
193
|
+
betterrank orphans --level symbol --kind function --root .
|
|
194
|
+
betterrank orphans --count --root .`,
|
|
195
|
+
|
|
149
196
|
reindex: `betterrank reindex [--root <path>]
|
|
150
197
|
|
|
151
198
|
Force a full rebuild of the index. Use after branch switches, large merges,
|
|
@@ -205,6 +252,34 @@ async function main() {
|
|
|
205
252
|
return; // Keep process alive (server is listening)
|
|
206
253
|
}
|
|
207
254
|
|
|
255
|
+
// Outline command — standalone, no CodeIndex needed
|
|
256
|
+
if (command === 'outline') {
|
|
257
|
+
const filePath = flags._positional[0];
|
|
258
|
+
if (!filePath) {
|
|
259
|
+
console.error('Usage: betterrank outline <file> [symbol1,symbol2]');
|
|
260
|
+
process.exit(1);
|
|
261
|
+
}
|
|
262
|
+
const expandSymbols = flags._positional[1] ? flags._positional[1].split(',') : [];
|
|
263
|
+
|
|
264
|
+
const root = flags.root ? resolve(flags.root) : process.cwd();
|
|
265
|
+
const absPath = isAbsolute(filePath) ? filePath : resolve(root, filePath);
|
|
266
|
+
|
|
267
|
+
let source;
|
|
268
|
+
try {
|
|
269
|
+
source = await readFile(absPath, 'utf-8');
|
|
270
|
+
} catch (err) {
|
|
271
|
+
console.error(`Cannot read file: ${absPath}`);
|
|
272
|
+
console.error(err.message);
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const relPath = relative(root, absPath);
|
|
277
|
+
const { buildOutline } = await import('./outline.js');
|
|
278
|
+
const result = buildOutline(source, relPath, expandSymbols);
|
|
279
|
+
console.log(result);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
208
283
|
const projectRoot = resolve(flags.root || process.cwd());
|
|
209
284
|
if (!flags.root) {
|
|
210
285
|
process.stderr.write(`⚠ No --root specified, using cwd: ${projectRoot}\n`);
|
|
@@ -465,6 +540,55 @@ async function main() {
|
|
|
465
540
|
break;
|
|
466
541
|
}
|
|
467
542
|
|
|
543
|
+
case 'orphans': {
|
|
544
|
+
const level = flags.level || 'file';
|
|
545
|
+
if (level !== 'file' && level !== 'symbol') {
|
|
546
|
+
console.error(`Unknown level: "${level}". Use "file" or "symbol".`);
|
|
547
|
+
process.exit(1);
|
|
548
|
+
}
|
|
549
|
+
const effectiveLimit = countMode ? undefined : (userLimit !== undefined ? userLimit : DEFAULT_LIMIT);
|
|
550
|
+
const result = await idx.orphans({ level, kind: flags.kind, count: countMode, offset, limit: effectiveLimit });
|
|
551
|
+
|
|
552
|
+
if (countMode) {
|
|
553
|
+
console.log(`total: ${result.total}`);
|
|
554
|
+
} else if (level === 'file') {
|
|
555
|
+
for (const f of result) {
|
|
556
|
+
console.log(`${f.file} (${f.symbolCount} symbols)`);
|
|
557
|
+
}
|
|
558
|
+
if (result.length === 0) {
|
|
559
|
+
console.log('(no orphan files found)');
|
|
560
|
+
} else {
|
|
561
|
+
const total = await idx.orphans({ level, count: true });
|
|
562
|
+
if (result.length < total.total) {
|
|
563
|
+
console.log(`\nShowing ${result.length} of ${total.total} orphan files (use --limit N for more)`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
} else {
|
|
567
|
+
// symbol level — group by file like map output
|
|
568
|
+
const byFile = new Map();
|
|
569
|
+
for (const s of result) {
|
|
570
|
+
if (!byFile.has(s.file)) byFile.set(s.file, []);
|
|
571
|
+
byFile.get(s.file).push(s);
|
|
572
|
+
}
|
|
573
|
+
for (const [file, syms] of byFile) {
|
|
574
|
+
console.log(`${file}:`);
|
|
575
|
+
for (const s of syms) {
|
|
576
|
+
console.log(` ${String(s.lineStart).padStart(4)}│ [${s.kind}] ${s.signature}`);
|
|
577
|
+
}
|
|
578
|
+
console.log('');
|
|
579
|
+
}
|
|
580
|
+
if (result.length === 0) {
|
|
581
|
+
console.log('(no orphan symbols found)');
|
|
582
|
+
} else {
|
|
583
|
+
const total = await idx.orphans({ level, kind: flags.kind, count: true });
|
|
584
|
+
if (result.length < total.total) {
|
|
585
|
+
console.log(`Showing ${result.length} of ${total.total} orphan symbols across ${byFile.size} files (use --limit N for more)`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
|
|
468
592
|
case 'reindex': {
|
|
469
593
|
const t0 = Date.now();
|
|
470
594
|
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/outline.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { parseFile, SUPPORTED_EXTENSIONS } from './parser.js';
|
|
2
|
+
import { extname } from 'path';
|
|
3
|
+
|
|
4
|
+
const MIN_COLLAPSE_LINES = 2;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build an outline view of a source file.
|
|
8
|
+
*
|
|
9
|
+
* Without expandSymbols: returns a skeleton with function/class bodies collapsed.
|
|
10
|
+
* With expandSymbols: returns the full source of the specified symbols.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} source - File contents
|
|
13
|
+
* @param {string} filePath - File path (for language detection)
|
|
14
|
+
* @param {string[]} expandSymbols - Symbol names to expand (empty = outline mode)
|
|
15
|
+
* @returns {string} Formatted output with line numbers
|
|
16
|
+
*/
|
|
17
|
+
export function buildOutline(source, filePath, expandSymbols = []) {
|
|
18
|
+
const lines = source.split('\n');
|
|
19
|
+
const pad = Math.max(String(lines.length).length, 4);
|
|
20
|
+
|
|
21
|
+
const ext = extname(filePath);
|
|
22
|
+
if (!SUPPORTED_EXTENSIONS.includes(ext)) {
|
|
23
|
+
return rawView(lines, pad);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const parsed = parseFile(filePath, source);
|
|
27
|
+
if (!parsed || parsed.definitions.length === 0) {
|
|
28
|
+
return rawView(lines, pad);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Deduplicate definitions by lineStart (decorated_definition can cause dupes)
|
|
32
|
+
const seenLines = new Set();
|
|
33
|
+
const defs = parsed.definitions
|
|
34
|
+
.filter(d => {
|
|
35
|
+
if (seenLines.has(d.lineStart)) return false;
|
|
36
|
+
seenLines.add(d.lineStart);
|
|
37
|
+
return true;
|
|
38
|
+
})
|
|
39
|
+
.sort((a, b) => a.lineStart - b.lineStart);
|
|
40
|
+
|
|
41
|
+
if (expandSymbols.length > 0) {
|
|
42
|
+
return expandMode(lines, defs, filePath, expandSymbols, pad);
|
|
43
|
+
}
|
|
44
|
+
return outlineMode(lines, defs, pad);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function rawView(lines, pad) {
|
|
48
|
+
return lines.map((l, i) => `${String(i + 1).padStart(pad)}│ ${l}`).join('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function expandMode(lines, defs, filePath, expandSymbols, pad) {
|
|
52
|
+
const output = [];
|
|
53
|
+
|
|
54
|
+
for (const symName of expandSymbols) {
|
|
55
|
+
const matches = defs.filter(d => d.name === symName);
|
|
56
|
+
|
|
57
|
+
if (matches.length === 0) {
|
|
58
|
+
output.push(`Symbol "${symName}" not found in ${filePath}`);
|
|
59
|
+
const similar = [...new Set(
|
|
60
|
+
defs.filter(d => d.name.toLowerCase().includes(symName.toLowerCase()))
|
|
61
|
+
.map(d => d.name)
|
|
62
|
+
)].slice(0, 5);
|
|
63
|
+
if (similar.length > 0) {
|
|
64
|
+
output.push(`Did you mean: ${similar.join(', ')}`);
|
|
65
|
+
} else {
|
|
66
|
+
output.push(`Available: ${defs.map(d => d.name).join(', ')}`);
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const def of matches) {
|
|
72
|
+
if (matches.length > 1 || expandSymbols.length > 1) {
|
|
73
|
+
output.push(`── ${def.name} (${filePath}:${def.lineStart}-${def.lineEnd}) ──`);
|
|
74
|
+
}
|
|
75
|
+
for (let i = def.lineStart; i <= def.lineEnd; i++) {
|
|
76
|
+
output.push(`${String(i).padStart(pad)}│ ${lines[i - 1]}`);
|
|
77
|
+
}
|
|
78
|
+
if (matches.length > 1) output.push('');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return output.join('\n').trimEnd();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function outlineMode(lines, defs, pad) {
|
|
86
|
+
// Detect containers: definitions that have child definitions inside them
|
|
87
|
+
const containers = new Set();
|
|
88
|
+
for (const def of defs) {
|
|
89
|
+
for (const other of defs) {
|
|
90
|
+
if (other === def) continue;
|
|
91
|
+
if (other.lineStart > def.lineStart && other.lineEnd <= def.lineEnd) {
|
|
92
|
+
containers.add(def);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Build collapse ranges for leaf definitions with sufficient body size
|
|
99
|
+
const collapseRanges = [];
|
|
100
|
+
for (const def of defs) {
|
|
101
|
+
if (containers.has(def)) continue;
|
|
102
|
+
if (!def.bodyStartLine) continue;
|
|
103
|
+
if (def.bodyStartLine > def.lineEnd) continue;
|
|
104
|
+
|
|
105
|
+
const bodyLineCount = def.lineEnd - def.bodyStartLine + 1;
|
|
106
|
+
if (bodyLineCount < MIN_COLLAPSE_LINES) continue;
|
|
107
|
+
|
|
108
|
+
collapseRanges.push({
|
|
109
|
+
start: def.bodyStartLine,
|
|
110
|
+
end: def.lineEnd,
|
|
111
|
+
lineCount: bodyLineCount,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
collapseRanges.sort((a, b) => a.start - b.start);
|
|
116
|
+
|
|
117
|
+
// Walk lines, skipping collapsed ranges
|
|
118
|
+
const output = [];
|
|
119
|
+
let lineNum = 1;
|
|
120
|
+
let rangeIdx = 0;
|
|
121
|
+
|
|
122
|
+
while (lineNum <= lines.length) {
|
|
123
|
+
if (rangeIdx < collapseRanges.length && collapseRanges[rangeIdx].start === lineNum) {
|
|
124
|
+
const range = collapseRanges[rangeIdx];
|
|
125
|
+
// Use the indent of the first body line for natural alignment
|
|
126
|
+
const bodyLine = lines[lineNum - 1];
|
|
127
|
+
const indent = bodyLine ? bodyLine.match(/^\s*/)[0] : '';
|
|
128
|
+
output.push(`${' '.repeat(pad)}│ ${indent}... (${range.lineCount} lines)`);
|
|
129
|
+
lineNum = range.end + 1;
|
|
130
|
+
rangeIdx++;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
output.push(`${String(lineNum).padStart(pad)}│ ${lines[lineNum - 1]}`);
|
|
135
|
+
lineNum++;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return output.join('\n');
|
|
139
|
+
}
|
package/src/parser.js
CHANGED
|
@@ -246,6 +246,26 @@ const KIND_MAP = {
|
|
|
246
246
|
decorated_definition: 'function',
|
|
247
247
|
};
|
|
248
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Find the body/block node of a definition, drilling into wrappers like
|
|
251
|
+
* lexical_declaration → variable_declarator → arrow_function → body.
|
|
252
|
+
*/
|
|
253
|
+
function findBodyNode(node) {
|
|
254
|
+
let body = node.childForFieldName('body');
|
|
255
|
+
if (body) return body;
|
|
256
|
+
|
|
257
|
+
for (let i = 0; i < node.namedChildCount; i++) {
|
|
258
|
+
const child = node.namedChild(i);
|
|
259
|
+
body = child.childForFieldName('body');
|
|
260
|
+
if (body) return body;
|
|
261
|
+
for (let j = 0; j < child.namedChildCount; j++) {
|
|
262
|
+
body = child.namedChild(j).childForFieldName('body');
|
|
263
|
+
if (body) return body;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
|
|
249
269
|
function nodeKind(nodeType) {
|
|
250
270
|
return KIND_MAP[nodeType] || 'other';
|
|
251
271
|
}
|
|
@@ -309,6 +329,17 @@ function parseFile(filePath, source) {
|
|
|
309
329
|
if (!nameCapture) continue;
|
|
310
330
|
const defNode = defCapture || nameCapture;
|
|
311
331
|
|
|
332
|
+
// Compute where body content starts (for outline collapsing)
|
|
333
|
+
const bodyNode = findBodyNode(defNode.node);
|
|
334
|
+
let bodyStartLine = null;
|
|
335
|
+
if (bodyNode) {
|
|
336
|
+
const bodyRow = bodyNode.startPosition.row; // 0-indexed
|
|
337
|
+
const defRow = defNode.node.startPosition.row; // 0-indexed
|
|
338
|
+
// If body opens on same line as def (JS: `function foo() {`),
|
|
339
|
+
// content starts on next line. Otherwise body IS the content.
|
|
340
|
+
bodyStartLine = bodyRow === defRow ? bodyRow + 2 : bodyRow + 1; // 1-indexed
|
|
341
|
+
}
|
|
342
|
+
|
|
312
343
|
definitions.push({
|
|
313
344
|
name: nameCapture.node.text,
|
|
314
345
|
kind: nodeKind(defNode.node.type),
|
|
@@ -316,6 +347,7 @@ function parseFile(filePath, source) {
|
|
|
316
347
|
lineStart: defNode.node.startPosition.row + 1,
|
|
317
348
|
lineEnd: defNode.node.endPosition.row + 1,
|
|
318
349
|
signature: extractSignature(defNode.node, langName),
|
|
350
|
+
bodyStartLine,
|
|
319
351
|
});
|
|
320
352
|
}
|
|
321
353
|
} catch (e) {
|
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);
|