@mishasinitcyn/betterrank 0.1.9 → 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 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.1.9",
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})
@@ -32,6 +34,26 @@ Global flags:
32
34
  `.trim();
33
35
 
34
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
+
35
57
  map: `betterrank map [--focus file1,file2] [--root <path>]
36
58
 
37
59
  Aider-style repo map: the most structurally important definitions ranked by PageRank.
@@ -230,6 +252,34 @@ async function main() {
230
252
  return; // Keep process alive (server is listening)
231
253
  }
232
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
+
233
283
  const projectRoot = resolve(flags.root || process.cwd());
234
284
  if (!flags.root) {
235
285
  process.stderr.write(`⚠ No --root specified, using cwd: ${projectRoot}\n`);
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) {