@mishasinitcyn/betterrank 0.1.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/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@mishasinitcyn/betterrank",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "betterrank": "src/cli.js"
9
+ },
10
+ "files": [
11
+ "src/",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "keywords": [
16
+ "code-index",
17
+ "pagerank",
18
+ "tree-sitter",
19
+ "repo-map",
20
+ "call-graph",
21
+ "dependency-analysis",
22
+ "codebase",
23
+ "ast"
24
+ ],
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/mishasinitcyn/betterrank.git"
29
+ },
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "dependencies": {
34
+ "tree-sitter": "^0.22.4",
35
+ "tree-sitter-javascript": "^0.23.1",
36
+ "tree-sitter-typescript": "^0.23.2",
37
+ "tree-sitter-python": "^0.23.6",
38
+ "tree-sitter-rust": "^0.23.2",
39
+ "tree-sitter-go": "^0.23.4",
40
+ "tree-sitter-ruby": "^0.23.1",
41
+ "tree-sitter-java": "^0.23.5",
42
+ "tree-sitter-c": "^0.23.4",
43
+ "tree-sitter-cpp": "^0.23.4",
44
+ "tree-sitter-c-sharp": "^0.23.1",
45
+ "tree-sitter-php": "^0.23.11",
46
+ "graphology": "^0.25.4",
47
+ "graphology-metrics": "^2.4.0",
48
+ "graphology-types": "^0.24.7",
49
+ "glob": "^11.0.1"
50
+ }
51
+ }
package/src/cache.js ADDED
@@ -0,0 +1,234 @@
1
+ import { createHash } from 'crypto';
2
+ import { stat, readFile } from 'fs/promises';
3
+ import { glob } from 'glob';
4
+ import { homedir, platform } from 'os';
5
+ import { join, relative } from 'path';
6
+ import { parseFile, SUPPORTED_EXTENSIONS } from './parser.js';
7
+ import {
8
+ buildGraph,
9
+ updateGraphFiles,
10
+ rankedSymbols,
11
+ saveGraph,
12
+ loadGraph,
13
+ } from './graph.js';
14
+
15
+ function getPlatformCacheDir() {
16
+ if (process.env.CODE_INDEX_CACHE_DIR) return process.env.CODE_INDEX_CACHE_DIR;
17
+
18
+ const home = homedir();
19
+ if (platform() === 'darwin') return join(home, 'Library', 'Caches', 'code-index');
20
+ if (platform() === 'win32') return join(process.env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'code-index', 'Cache');
21
+ return join(process.env.XDG_CACHE_HOME || join(home, '.cache'), 'code-index');
22
+ }
23
+
24
+ const CACHE_DIR = getPlatformCacheDir();
25
+
26
+ const IGNORE_PATTERNS = [
27
+ // Dependencies & generated
28
+ '**/node_modules/**',
29
+ '**/dist/**',
30
+ '**/build/**',
31
+ '**/coverage/**',
32
+ '**/vendor/**',
33
+ '**/*.min.js',
34
+ '**/*.bundle.js',
35
+ '**/*.map',
36
+
37
+ // VCS & tool caches
38
+ '**/.git/**',
39
+ '**/.code-index/**',
40
+ '**/.claude/**',
41
+ '**/.cursor/**',
42
+
43
+ // Language-specific caches
44
+ '**/__pycache__/**',
45
+ '**/.venv/**',
46
+ '**/.next/**',
47
+ '**/.nuxt/**',
48
+
49
+ // iOS / mobile vendor
50
+ '**/Pods/**',
51
+ '**/*.xcframework/**',
52
+
53
+ // Scratch / temp
54
+ 'tmp/**',
55
+ ];
56
+
57
+ const CONFIG_PATH = '.code-index/config.json';
58
+
59
+ /**
60
+ * Derive a deterministic cache filename from the project root path.
61
+ * Uses a short hash so cache files are grouped under one central directory.
62
+ */
63
+ function cachePathForRoot(projectRoot) {
64
+ const hash = createHash('sha256').update(projectRoot).digest('hex').slice(0, 12);
65
+ return join(CACHE_DIR, `${hash}.json`);
66
+ }
67
+
68
+ class CodeIndexCache {
69
+ constructor(projectRoot, opts = {}) {
70
+ this.projectRoot = projectRoot;
71
+ this.cachePath = opts.cachePath ? join(projectRoot, opts.cachePath) : cachePathForRoot(projectRoot);
72
+ this.configPath = join(projectRoot, CONFIG_PATH);
73
+ this.graph = null;
74
+ this.mtimes = new Map();
75
+ this.initialized = false;
76
+ this.extensions = opts.extensions || SUPPORTED_EXTENSIONS;
77
+ this.ignorePatterns = [...IGNORE_PATTERNS, ...(opts.ignore || [])];
78
+ }
79
+
80
+ /**
81
+ * Ensure the index is loaded and up-to-date.
82
+ * Lazy-initializes on first call; incremental updates on subsequent calls.
83
+ */
84
+ async ensure() {
85
+ if (!this.initialized) {
86
+ await this._loadConfig();
87
+
88
+ const cached = await loadGraph(this.cachePath);
89
+ if (cached) {
90
+ this.graph = cached.graph;
91
+ this.mtimes = cached.mtimes;
92
+ }
93
+ this.initialized = true;
94
+ }
95
+
96
+ const { changed, deleted } = await this._getChangedFiles();
97
+
98
+ if (changed.length === 0 && deleted.length === 0) {
99
+ if (!this.graph) {
100
+ // First run, no cache, no files — empty graph
101
+ const graphology = await import('graphology');
102
+ const MDG = graphology.default?.MultiDirectedGraph || graphology.MultiDirectedGraph;
103
+ this.graph = new MDG({ allowSelfLoops: false });
104
+ }
105
+ return { changed: 0, deleted: 0 };
106
+ }
107
+
108
+ const newSymbols = await this._parseFiles(changed);
109
+
110
+ if (!this.graph) {
111
+ // Full build from scratch
112
+ this.graph = buildGraph(newSymbols);
113
+ } else {
114
+ // Incremental update
115
+ const allRemoved = [...deleted, ...changed];
116
+ updateGraphFiles(this.graph, allRemoved, newSymbols);
117
+ }
118
+
119
+ await saveGraph(this.graph, this.mtimes, this.cachePath);
120
+
121
+ return { changed: changed.length, deleted: deleted.length };
122
+ }
123
+
124
+ /**
125
+ * Load project-level config from .code-index/config.json.
126
+ * Merges extra ignore patterns with built-in defaults.
127
+ *
128
+ * Config format:
129
+ * {
130
+ * "ignore": ["extra/pattern/**", "another/**"]
131
+ * }
132
+ */
133
+ async _loadConfig() {
134
+ try {
135
+ const raw = JSON.parse(await readFile(this.configPath, 'utf-8'));
136
+ if (Array.isArray(raw.ignore)) {
137
+ this.ignorePatterns = [...this.ignorePatterns, ...raw.ignore];
138
+ }
139
+ } catch {
140
+ // No config file or invalid JSON — use defaults only
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Force a full reindex from scratch.
146
+ */
147
+ async reindex() {
148
+ this.graph = null;
149
+ this.mtimes = new Map();
150
+ this.initialized = false;
151
+
152
+ // Delete the cache file
153
+ try {
154
+ const { unlink } = await import('fs/promises');
155
+ await unlink(this.cachePath);
156
+ } catch {
157
+ // doesn't exist, fine
158
+ }
159
+
160
+ return this.ensure();
161
+ }
162
+
163
+ /**
164
+ * Walk the project tree and find files that have changed since last parse.
165
+ */
166
+ async _getChangedFiles() {
167
+ const pattern = `**/*{${this.extensions.join(',')}}`;
168
+ const files = await glob(pattern, {
169
+ cwd: this.projectRoot,
170
+ ignore: this.ignorePatterns,
171
+ absolute: true,
172
+ nodir: true,
173
+ });
174
+
175
+ const changed = [];
176
+ const currentFiles = new Set();
177
+
178
+ for (const absPath of files) {
179
+ const relPath = relative(this.projectRoot, absPath);
180
+ currentFiles.add(relPath);
181
+
182
+ try {
183
+ const { mtimeMs } = await stat(absPath);
184
+ const storedMtime = this.mtimes.get(relPath);
185
+ if (storedMtime === undefined || storedMtime < mtimeMs) {
186
+ changed.push(relPath);
187
+ this.mtimes.set(relPath, mtimeMs);
188
+ }
189
+ } catch {
190
+ // file disappeared between glob and stat
191
+ }
192
+ }
193
+
194
+ const deleted = [];
195
+ for (const [f] of this.mtimes) {
196
+ if (!currentFiles.has(f)) {
197
+ deleted.push(f);
198
+ this.mtimes.delete(f);
199
+ }
200
+ }
201
+
202
+ return { changed, deleted };
203
+ }
204
+
205
+ /**
206
+ * Parse a batch of files and return their symbol data.
207
+ */
208
+ async _parseFiles(relPaths) {
209
+ const results = [];
210
+
211
+ for (const relPath of relPaths) {
212
+ const absPath = join(this.projectRoot, relPath);
213
+ try {
214
+ const source = await readFile(absPath, 'utf-8');
215
+ const result = await parseFile(relPath, source);
216
+ if (result) results.push(result);
217
+ } catch {
218
+ // skip unparseable files
219
+ }
220
+ }
221
+
222
+ return results;
223
+ }
224
+
225
+ getGraph() {
226
+ return this.graph;
227
+ }
228
+
229
+ getMtimes() {
230
+ return this.mtimes;
231
+ }
232
+ }
233
+
234
+ export { CodeIndexCache, CACHE_DIR, cachePathForRoot };
package/src/cli.js ADDED
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { CodeIndex } from './index.js';
4
+ import { resolve, relative, isAbsolute } from 'path';
5
+
6
+ const DEFAULT_LIMIT = 50;
7
+ const DEFAULT_DEPTH = 3;
8
+
9
+ const USAGE = `
10
+ code-index <command> [options]
11
+
12
+ Commands:
13
+ map [--focus file1,file2] Repo map (ranked by PageRank)
14
+ search <query> [--kind type] Substring search on symbol names + signatures (ranked by PageRank)
15
+ structure [--depth N] File tree with symbol counts (default depth: ${DEFAULT_DEPTH})
16
+ symbols [--file path] [--kind type] List definitions (ranked by PageRank)
17
+ callers <symbol> [--file path] All call sites (ranked by importance)
18
+ deps <file> What this file imports (ranked)
19
+ dependents <file> What imports this file (ranked)
20
+ neighborhood <file> [--hops N] [--max-files N] Local subgraph (ranked by PageRank)
21
+ reindex Force full rebuild
22
+ stats Index statistics
23
+
24
+ Global flags:
25
+ --root <path> Project root (default: cwd). Always pass this explicitly.
26
+ --count Return counts only (no content)
27
+ --offset N Skip first N results
28
+ --limit N Max results to return (default: ${DEFAULT_LIMIT} for list commands)
29
+ `.trim();
30
+
31
+ async function main() {
32
+ const args = process.argv.slice(2);
33
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
34
+ console.log(USAGE);
35
+ process.exit(0);
36
+ }
37
+
38
+ const command = args[0];
39
+ const flags = parseFlags(args.slice(1));
40
+ const projectRoot = resolve(flags.root || process.cwd());
41
+ if (!flags.root) {
42
+ process.stderr.write(`⚠ No --root specified, using cwd: ${projectRoot}\n`);
43
+ }
44
+ const idx = new CodeIndex(projectRoot);
45
+
46
+ const countMode = flags.count === true;
47
+ const offset = flags.offset !== undefined ? parseInt(flags.offset, 10) : undefined;
48
+ const userLimit = flags.limit !== undefined ? parseInt(flags.limit, 10) : undefined;
49
+
50
+ // Normalize a file path argument relative to projectRoot.
51
+ // Handles cases like `neighborhood gravity-engine/src/foo.py --root gravity-engine`
52
+ // where the graph stores `src/foo.py` but the user passes the full path.
53
+ function normalizeFilePath(filePath) {
54
+ if (!filePath) return filePath;
55
+ const abs = resolve(filePath);
56
+ const rel = relative(projectRoot, abs);
57
+ // If the result starts with '..' the file is outside projectRoot — return as-is
58
+ if (rel.startsWith('..')) return filePath;
59
+ return rel;
60
+ }
61
+
62
+ switch (command) {
63
+ case 'map': {
64
+ const focusFiles = flags.focus ? flags.focus.split(',') : [];
65
+ const effectiveLimit = countMode ? undefined : (userLimit !== undefined ? userLimit : DEFAULT_LIMIT);
66
+ const result = await idx.map({ focusFiles, count: countMode, offset, limit: effectiveLimit });
67
+ if (countMode) {
68
+ console.log(`total: ${result.total}`);
69
+ } else {
70
+ console.log(result.content);
71
+ if (result.shownSymbols < result.totalSymbols) {
72
+ console.log(`\nShowing ${result.shownFiles} of ${result.totalFiles} files, ${result.shownSymbols} of ${result.totalSymbols} symbols (ranked by PageRank)`);
73
+ console.log(`Use --limit N to show more, or --count for totals`);
74
+ }
75
+ }
76
+ break;
77
+ }
78
+
79
+ case 'search': {
80
+ const query = flags._positional[0];
81
+ if (!query) { console.error('Usage: code-index search <query> [--kind type]'); process.exit(1); }
82
+ const effectiveLimit = countMode ? undefined : (userLimit !== undefined ? userLimit : DEFAULT_LIMIT);
83
+ const result = await idx.search({ query, kind: flags.kind, count: countMode, offset, limit: effectiveLimit });
84
+ if (countMode) {
85
+ console.log(`total: ${result.total}`);
86
+ } else {
87
+ for (const s of result) {
88
+ console.log(`${s.file}:${s.lineStart} [${s.kind}] ${s.signature}`);
89
+ }
90
+ if (result.length === 0) {
91
+ console.log('(no matches)');
92
+ } else if (result.length === effectiveLimit && userLimit === undefined) {
93
+ console.log(`\n(showing top ${effectiveLimit} by relevance — use --limit N or --count for total)`);
94
+ }
95
+ }
96
+ break;
97
+ }
98
+
99
+ case 'structure': {
100
+ const depth = flags.depth ? parseInt(flags.depth, 10) : DEFAULT_DEPTH;
101
+ const result = await idx.structure({ depth });
102
+ console.log(result);
103
+ if (!flags.depth) {
104
+ console.log(`\n(default depth ${DEFAULT_DEPTH} — use --depth N to expand)`);
105
+ }
106
+ break;
107
+ }
108
+
109
+ case 'symbols': {
110
+ const effectiveLimit = countMode ? undefined : (userLimit !== undefined ? userLimit : DEFAULT_LIMIT);
111
+ const result = await idx.symbols({ file: normalizeFilePath(flags.file), kind: flags.kind, count: countMode, offset, limit: effectiveLimit });
112
+ if (countMode) {
113
+ console.log(`total: ${result.total}`);
114
+ } else {
115
+ for (const s of result) {
116
+ console.log(`${s.file}:${s.lineStart} [${s.kind}] ${s.signature}`);
117
+ }
118
+ if (result.length === 0) {
119
+ console.log('(no symbols found)');
120
+ } else if (result.length === effectiveLimit && userLimit === undefined) {
121
+ console.log(`\n(showing top ${effectiveLimit} by relevance — use --limit N or --count for total)`);
122
+ }
123
+ }
124
+ break;
125
+ }
126
+
127
+ case 'callers': {
128
+ const symbol = flags._positional[0];
129
+ if (!symbol) { console.error('Usage: code-index callers <symbol> [--file path]'); process.exit(1); }
130
+ const effectiveLimit = countMode ? undefined : (userLimit !== undefined ? userLimit : DEFAULT_LIMIT);
131
+ const result = await idx.callers({ symbol, file: normalizeFilePath(flags.file), count: countMode, offset, limit: effectiveLimit });
132
+ if (countMode) {
133
+ console.log(`total: ${result.total}`);
134
+ } else {
135
+ for (const c of result) {
136
+ console.log(c.file);
137
+ }
138
+ if (result.length === 0) {
139
+ console.log('(no callers found)');
140
+ } else if (result.length === effectiveLimit && userLimit === undefined) {
141
+ console.log(`\n(showing top ${effectiveLimit} by relevance — use --limit N or --count for total)`);
142
+ }
143
+ }
144
+ break;
145
+ }
146
+
147
+ case 'deps': {
148
+ const file = normalizeFilePath(flags._positional[0]);
149
+ if (!file) { console.error('Usage: code-index deps <file>'); process.exit(1); }
150
+ const effectiveLimit = countMode ? undefined : (userLimit !== undefined ? userLimit : DEFAULT_LIMIT);
151
+ const result = await idx.dependencies({ file, count: countMode, offset, limit: effectiveLimit });
152
+ if (countMode) {
153
+ console.log(`total: ${result.total}`);
154
+ } else {
155
+ for (const d of result) console.log(d);
156
+ if (result.length === 0) {
157
+ console.log('(no dependencies)');
158
+ } else if (result.length === effectiveLimit && userLimit === undefined) {
159
+ console.log(`\n(showing top ${effectiveLimit} by relevance — use --limit N or --count for total)`);
160
+ }
161
+ }
162
+ break;
163
+ }
164
+
165
+ case 'dependents': {
166
+ const file = normalizeFilePath(flags._positional[0]);
167
+ if (!file) { console.error('Usage: code-index dependents <file>'); process.exit(1); }
168
+ const effectiveLimit = countMode ? undefined : (userLimit !== undefined ? userLimit : DEFAULT_LIMIT);
169
+ const result = await idx.dependents({ file, count: countMode, offset, limit: effectiveLimit });
170
+ if (countMode) {
171
+ console.log(`total: ${result.total}`);
172
+ } else {
173
+ for (const d of result) console.log(d);
174
+ if (result.length === 0) {
175
+ console.log('(no dependents)');
176
+ } else if (result.length === effectiveLimit && userLimit === undefined) {
177
+ console.log(`\n(showing top ${effectiveLimit} by relevance — use --limit N or --count for total)`);
178
+ }
179
+ }
180
+ break;
181
+ }
182
+
183
+ case 'neighborhood': {
184
+ const file = normalizeFilePath(flags._positional[0]);
185
+ if (!file) { console.error('Usage: code-index neighborhood <file> [--hops N] [--max-files N]'); process.exit(1); }
186
+ const hops = parseInt(flags.hops || '2', 10);
187
+ const maxFilesFlag = flags['max-files'] ? parseInt(flags['max-files'], 10) : 15;
188
+
189
+ // Safety: if neither --count nor --limit/--offset was provided, force a
190
+ // count-first response so we never accidentally dump hundreds of files.
191
+ const needsSafetyCount = !countMode && offset === undefined && userLimit === undefined;
192
+
193
+ if (needsSafetyCount) {
194
+ const preview = await idx.neighborhood({
195
+ file, hops, maxFiles: maxFilesFlag, count: true,
196
+ });
197
+ console.log(`files: ${preview.totalFiles} (${preview.totalVisited} visited, ${preview.totalFiles} after ranking)`);
198
+ console.log(`symbols: ${preview.totalSymbols}`);
199
+ console.log(`edges: ${preview.totalEdges}`);
200
+ console.log(`\nUse --limit N (and --offset N) to paginate, or --count to get counts only.`);
201
+ break;
202
+ }
203
+
204
+ const result = await idx.neighborhood({
205
+ file, hops, maxFiles: maxFilesFlag,
206
+ count: countMode, offset, limit: userLimit,
207
+ });
208
+
209
+ if (countMode) {
210
+ console.log(`files: ${result.totalFiles} (${result.totalVisited} visited, ${result.totalFiles} after ranking)`);
211
+ console.log(`symbols: ${result.totalSymbols}`);
212
+ console.log(`edges: ${result.totalEdges}`);
213
+ } else {
214
+ if (result.total !== undefined && result.total > result.files.length) {
215
+ console.log(`Files (${result.files.length} of ${result.total}):`);
216
+ } else {
217
+ console.log(`Files (${result.files.length}):`);
218
+ }
219
+ for (const f of result.files) console.log(` ${f}`);
220
+
221
+ if (result.edges.length > 0) {
222
+ console.log(`\nImports (${result.edges.length}):`);
223
+ for (const e of result.edges) console.log(` ${e.source} → ${e.target}`);
224
+ }
225
+
226
+ if (result.symbols.length > 0) {
227
+ console.log(`\nSymbols (${result.symbols.length}):`);
228
+ const byFile = new Map();
229
+ for (const s of result.symbols) {
230
+ if (!byFile.has(s.file)) byFile.set(s.file, []);
231
+ byFile.get(s.file).push(s);
232
+ }
233
+ for (const [f, syms] of byFile) {
234
+ console.log(` ${f}:`);
235
+ for (const s of syms) {
236
+ console.log(` ${String(s.lineStart).padStart(4)}│ ${s.signature}`);
237
+ }
238
+ }
239
+ }
240
+ }
241
+ break;
242
+ }
243
+
244
+ case 'reindex': {
245
+ const t0 = Date.now();
246
+ const result = await idx.reindex();
247
+ const elapsed = Date.now() - t0;
248
+ const st = await idx.stats();
249
+ console.log(`Reindexed in ${elapsed}ms: ${st.files} files, ${st.symbols} symbols, ${st.edges} edges`);
250
+ break;
251
+ }
252
+
253
+ case 'stats': {
254
+ await idx._ensureReady();
255
+ const st = await idx.stats();
256
+ console.log(`Files: ${st.files}`);
257
+ console.log(`Symbols: ${st.symbols}`);
258
+ console.log(`Edges: ${st.edges}`);
259
+ break;
260
+ }
261
+
262
+ default:
263
+ console.error(`Unknown command: ${command}`);
264
+ console.log(USAGE);
265
+ process.exit(1);
266
+ }
267
+ }
268
+
269
+ function parseFlags(args) {
270
+ const flags = { _positional: [] };
271
+ let i = 0;
272
+ while (i < args.length) {
273
+ if (args[i].startsWith('--')) {
274
+ const key = args[i].substring(2);
275
+ if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
276
+ flags[key] = args[i + 1];
277
+ i += 2;
278
+ } else {
279
+ flags[key] = true;
280
+ i++;
281
+ }
282
+ } else {
283
+ flags._positional.push(args[i]);
284
+ i++;
285
+ }
286
+ }
287
+ return flags;
288
+ }
289
+
290
+ main().catch(err => {
291
+ console.error(err);
292
+ process.exit(1);
293
+ });