graph-indexer 2.1.1 → 3.0.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/CHANGELOG.md +260 -1
- package/README.md +232 -312
- package/SECURITY.md +37 -59
- package/bin/graph-indexer.mjs +251 -0
- package/integrations/claude-code/.claude-plugin/plugin.json +8 -0
- package/integrations/claude-code/.mcp.json +8 -0
- package/integrations/claude-code/agents/code-structure.md +22 -0
- package/integrations/claude-code/hooks/gi-hook.mjs +99 -0
- package/integrations/claude-code/hooks/hooks.json +16 -0
- package/integrations/opencode/graph-indexer.js +124 -0
- package/package.json +32 -109
- package/src/cli/helper.mjs +67 -0
- package/src/cli/hook.mjs +356 -0
- package/src/cli/init.mjs +255 -0
- package/src/cli/resident.mjs +175 -0
- package/src/index/discover.mjs +130 -0
- package/src/index/fingerprint.mjs +30 -0
- package/src/index/graph.mjs +61 -0
- package/src/index/indexer.mjs +476 -0
- package/src/index/modules.mjs +331 -0
- package/src/index/resolver.mjs +954 -0
- package/src/mcp/render.mjs +60 -0
- package/src/mcp/server.mjs +147 -0
- package/src/mcp/tools.mjs +973 -0
- package/src/parse/arity.mjs +152 -0
- package/src/parse/extract.mjs +1031 -0
- package/src/parse/lang/csharp.mjs +106 -0
- package/src/parse/lang/ecma.mjs +438 -0
- package/src/parse/lang/go.mjs +194 -0
- package/src/parse/lang/jvm.mjs +118 -0
- package/src/parse/lang/kotlin.mjs +97 -0
- package/src/parse/lang/misc.mjs +115 -0
- package/src/parse/lang/native.mjs +126 -0
- package/src/parse/lang/python.mjs +153 -0
- package/src/parse/lang/rust.mjs +157 -0
- package/src/parse/lang/scripting.mjs +219 -0
- package/src/parse/languages.mjs +48 -0
- package/src/parse/runtime.mjs +56 -0
- package/src/query/check.mjs +343 -0
- package/src/query/facts.mjs +323 -0
- package/src/query/intel.mjs +680 -0
- package/src/query/read.mjs +81 -0
- package/src/query/testcmd.mjs +119 -0
- package/src/query/textsearch.mjs +160 -0
- package/src/search/search.mjs +210 -0
- package/src/search/tokenize.mjs +188 -0
- package/src/store/db.mjs +169 -0
- package/src/util/paths.mjs +24 -0
- package/vendor/grammars/bash.wasm.gz +0 -0
- package/vendor/grammars/c.wasm.gz +0 -0
- package/vendor/grammars/c_sharp.wasm.gz +0 -0
- package/vendor/grammars/cpp.wasm.gz +0 -0
- package/vendor/grammars/css.wasm.gz +0 -0
- package/vendor/grammars/go.wasm.gz +0 -0
- package/vendor/grammars/java.wasm.gz +0 -0
- package/vendor/grammars/javascript.wasm.gz +0 -0
- package/vendor/grammars/kotlin.wasm.gz +0 -0
- package/vendor/grammars/manifest.json +148 -0
- package/vendor/grammars/php.wasm.gz +0 -0
- package/vendor/grammars/python.wasm.gz +0 -0
- package/vendor/grammars/ruby.wasm.gz +0 -0
- package/vendor/grammars/rust.wasm.gz +0 -0
- package/vendor/grammars/scala.wasm.gz +0 -0
- package/vendor/grammars/tsx.wasm.gz +0 -0
- package/vendor/grammars/typescript.wasm.gz +0 -0
- package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
- package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
- package/cli-ui.mjs +0 -78
- package/colbert.mjs +0 -168
- package/config.mjs +0 -494
- package/daemon-ctl.mjs +0 -175
- package/daemon-lock.mjs +0 -69
- package/embedders/python/mlx_embed_server.py +0 -61
- package/embedders/python/requirements-mlx.txt +0 -5
- package/embedders/setup-mlx.mjs +0 -110
- package/embeddings.mjs +0 -571
- package/engine/binary.mjs +0 -437
- package/engine/memory.mjs +0 -1003
- package/engine/sqlite.mjs +0 -1136
- package/enrichment.mjs +0 -521
- package/git-signals.mjs +0 -248
- package/indexer.mjs +0 -546
- package/init.mjs +0 -1809
- package/layout.mjs +0 -123
- package/mcp/centrality.mjs +0 -117
- package/mcp/format.mjs +0 -61
- package/mcp/resolver.mjs +0 -116
- package/mcp/symbolgraph.mjs +0 -137
- package/mcp/taint.mjs +0 -225
- package/mcp/tools.mjs +0 -1582
- package/mcp/topology.mjs +0 -676
- package/mcp-server.mjs +0 -197
- package/parse/extractor.mjs +0 -628
- package/parse/imports.mjs +0 -365
- package/parse/interprocedural.mjs +0 -134
- package/parse/languages.mjs +0 -253
- package/parse/metadata.mjs +0 -701
- package/parse/routes.mjs +0 -327
- package/parse/scip.mjs +0 -391
- package/parse/taint-patterns.mjs +0 -119
- package/prompts/CORE.md +0 -91
- package/prompts/DOMAIN_TEMPLATE.md +0 -46
- package/prompts/INTEGRATION.md +0 -123
- package/prompts/frameworks/ANDROID.md +0 -17
- package/prompts/frameworks/ASPNET_CORE.md +0 -19
- package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
- package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
- package/prompts/frameworks/RAILS.md +0 -20
- package/prompts/frameworks/REACT.md +0 -20
- package/prompts/frameworks/SPRING_BOOT.md +0 -20
- package/prompts/languages/BASH.md +0 -22
- package/prompts/languages/C.md +0 -21
- package/prompts/languages/CSHARP.md +0 -24
- package/prompts/languages/CSS_SCSS.md +0 -22
- package/prompts/languages/GO.md +0 -24
- package/prompts/languages/JAVA.md +0 -23
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
- package/prompts/languages/KOTLIN.md +0 -24
- package/prompts/languages/PHP.md +0 -23
- package/prompts/languages/PYTHON.md +0 -23
- package/prompts/languages/RUBY.md +0 -23
- package/prompts/languages/RUST.md +0 -21
- package/prompts/languages/SWIFT.md +0 -21
- package/seal.mjs +0 -400
- package/search-core.mjs +0 -777
- package/search-sparse.mjs +0 -183
- package/storage.mjs +0 -73
- package/watch-daemon.mjs +0 -289
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodeIntel: the query facade used by the MCP tools and the CLI.
|
|
3
|
+
*
|
|
4
|
+
* Every public query first calls ensureFresh(): files touched since the last check are
|
|
5
|
+
* re-indexed before answering, and the files a result points into are re-validated, so an agent
|
|
6
|
+
* that just edited code never receives stale line numbers or stale call graphs.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { Store } from '../store/db.mjs';
|
|
12
|
+
import { Indexer } from '../index/indexer.mjs';
|
|
13
|
+
import { SearchEngine } from '../search/search.mjs';
|
|
14
|
+
import { computeCentrality, loadGraph } from '../index/graph.mjs';
|
|
15
|
+
import { confidenceLabel } from '../index/resolver.mjs';
|
|
16
|
+
import { dataDir } from '../util/paths.mjs';
|
|
17
|
+
import { isConstructor } from '../parse/languages.mjs';
|
|
18
|
+
import { sigArity } from '../parse/arity.mjs';
|
|
19
|
+
|
|
20
|
+
const TYPE_KINDS = new Set(['class', 'interface', 'struct', 'enum', 'trait', 'type', 'object', 'module', 'impl']);
|
|
21
|
+
const CALLABLE = new Set(['function', 'method', 'constructor', 'macro']);
|
|
22
|
+
const DEP_KINDS = ['call', 'new', 'inherit', 'type', 'decorator', 'value', 'read'];
|
|
23
|
+
// examples, docs snippets and sample apps are visited after library code and tests
|
|
24
|
+
const EXAMPLE_PATH = /(^|\/)(docs?_src|docs?|examples?|samples?|demos?|tutorials?|benchmarks?|playground)\//i;
|
|
25
|
+
|
|
26
|
+
export class CodeIntel {
|
|
27
|
+
constructor({ root, dbPath = null, log = () => {}, watch = false }) {
|
|
28
|
+
this.root = path.resolve(root);
|
|
29
|
+
this.dbPath = dbPath ?? path.join(dataDir(this.root), 'index.db');
|
|
30
|
+
this.log = log;
|
|
31
|
+
this.watchEnabled = watch;
|
|
32
|
+
this.dirty = new Set();
|
|
33
|
+
this.lastSweep = 0;
|
|
34
|
+
this.sweepIntervalMs = 3000;
|
|
35
|
+
this.watcher = null;
|
|
36
|
+
this.watcherHealthy = false;
|
|
37
|
+
this.lastSyncStats = null;
|
|
38
|
+
this.fileCache = new Map();
|
|
39
|
+
this._centrality = null;
|
|
40
|
+
this._centralityVersion = -1;
|
|
41
|
+
this.indexing = null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── lifecycle ────────────────────────────────────────────────────────────────
|
|
45
|
+
/**
|
|
46
|
+
* Open the store and reconcile with the working tree. With `background: true` the initial
|
|
47
|
+
* sync runs asynchronously (queries answer from the partial index meanwhile) — used by the
|
|
48
|
+
* MCP server so the first tool call never times out on a large repository.
|
|
49
|
+
*/
|
|
50
|
+
async open({ onProgress = null, background = false } = {}) {
|
|
51
|
+
this.store = new Store(this.dbPath);
|
|
52
|
+
this.ix = new Indexer({ root: this.root, store: this.store, log: this.log });
|
|
53
|
+
this.ix.load();
|
|
54
|
+
this.search = new SearchEngine(this.ix, { centrality: () => this.centrality() });
|
|
55
|
+
if (this.watchEnabled) this.#startWatcher();
|
|
56
|
+
this.initialDone = false;
|
|
57
|
+
this.progress = null;
|
|
58
|
+
const p = this.ix.sync({ onProgress: (done, total) => { this.progress = { done, total }; onProgress?.(done, total); } })
|
|
59
|
+
.then((res) => { this.lastSyncStats = res; this.lastSweep = Date.now(); this.initialDone = true; this.progress = null; return res; })
|
|
60
|
+
.finally(() => { if (this.indexing === p) this.indexing = null; });
|
|
61
|
+
this.indexing = p;
|
|
62
|
+
this.ready = p;
|
|
63
|
+
if (!background) await p;
|
|
64
|
+
return background ? null : p;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
close() {
|
|
68
|
+
try { this.watcher?.close(); } catch { /* closed */ }
|
|
69
|
+
this.store?.close();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#startWatcher() {
|
|
73
|
+
try {
|
|
74
|
+
this.watcher = fs.watch(this.root, { recursive: true, persistent: false }, (_ev, file) => {
|
|
75
|
+
if (!file) { this.lastSweep = 0; return; }
|
|
76
|
+
const rel = String(file).split(path.sep).join('/');
|
|
77
|
+
if (rel.startsWith('.git/') || rel.includes('/.git/') || rel.startsWith('.graph-indexer/') || rel.includes('node_modules/')) return;
|
|
78
|
+
this.dirty.add(rel);
|
|
79
|
+
});
|
|
80
|
+
this.watcher.on?.('error', () => { this.watcherHealthy = false; });
|
|
81
|
+
this.watcherHealthy = true;
|
|
82
|
+
} catch (e) {
|
|
83
|
+
this.log(`file watcher unavailable (${e.message}); falling back to periodic stat sweeps`);
|
|
84
|
+
this.watcherHealthy = false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Freshness contract: before answering, apply pending file changes. With a healthy watcher we
|
|
90
|
+
* re-index only the dirty paths (plus a periodic full sweep as a safety net); without one we
|
|
91
|
+
* do a stat sweep at most every few seconds (cheap: size+mtime compare).
|
|
92
|
+
*/
|
|
93
|
+
async ensureFresh() {
|
|
94
|
+
if (!this.initialDone) return null; // initial build in progress: answer from the partial index
|
|
95
|
+
if (this.indexing) await this.indexing;
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
const sweepDue = now - this.lastSweep > (this.watcherHealthy ? 30_000 : this.sweepIntervalMs);
|
|
98
|
+
let res = null;
|
|
99
|
+
if (sweepDue) {
|
|
100
|
+
this.dirty.clear();
|
|
101
|
+
this.indexing = this.ix.sync();
|
|
102
|
+
try { res = await this.indexing; } finally { this.indexing = null; }
|
|
103
|
+
this.lastSweep = Date.now();
|
|
104
|
+
} else if (this.dirty.size) {
|
|
105
|
+
const paths = [...this.dirty];
|
|
106
|
+
this.dirty.clear();
|
|
107
|
+
this.indexing = this.ix.syncPaths(paths);
|
|
108
|
+
try { res = await this.indexing; } finally { this.indexing = null; }
|
|
109
|
+
}
|
|
110
|
+
if (res && (res.changed || res.added || res.removed)) this.lastSyncStats = { ...res, at: Date.now() };
|
|
111
|
+
return res;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Re-validate specific files right before rendering them (cheap stat). */
|
|
115
|
+
async revalidate(paths) {
|
|
116
|
+
const stale = this.ix.staleAmong(paths);
|
|
117
|
+
if (stale.length) await this.ix.syncPaths(stale);
|
|
118
|
+
return stale;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
centrality() {
|
|
122
|
+
if (this._centralityVersion !== this.ix.version || !this._centrality) {
|
|
123
|
+
this._centrality = computeCentrality(this.store);
|
|
124
|
+
this._centralityVersion = this.ix.version;
|
|
125
|
+
}
|
|
126
|
+
return this._centrality;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── source access ────────────────────────────────────────────────────────────
|
|
130
|
+
fileLines(rel) {
|
|
131
|
+
const abs = path.join(this.root, rel);
|
|
132
|
+
let st;
|
|
133
|
+
try { st = fs.statSync(abs); } catch { return null; }
|
|
134
|
+
const key = rel;
|
|
135
|
+
const c = this.fileCache.get(key);
|
|
136
|
+
if (c && c.mtime === st.mtimeMs && c.size === st.size) return c.lines;
|
|
137
|
+
let text;
|
|
138
|
+
try { text = fs.readFileSync(abs, 'utf8'); } catch { return null; }
|
|
139
|
+
const lines = text.split(/\r?\n/);
|
|
140
|
+
this.fileCache.set(key, { mtime: st.mtimeMs, size: st.size, lines });
|
|
141
|
+
if (this.fileCache.size > 256) this.fileCache.delete(this.fileCache.keys().next().value);
|
|
142
|
+
return lines;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── symbol lookup ────────────────────────────────────────────────────────────
|
|
146
|
+
sym(id) {
|
|
147
|
+
return this.store.get(`SELECT s.*, f.path, f.lang, f.is_test FROM symbols s JOIN files f ON f.id = s.file_id WHERE s.id = ?`, id) ?? null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolve a user-supplied target to symbols. Accepts:
|
|
152
|
+
* `name` · `Class.method` · `path/file.ts:Class.method` · `path/file.ts#qname` · `path/file.ts:42`
|
|
153
|
+
* @returns {{ matches: object[], how: string }}
|
|
154
|
+
*/
|
|
155
|
+
findSymbols(target, { limit = 20 } = {}) {
|
|
156
|
+
let t = String(target).trim().replace(/\(\)$/, '');
|
|
157
|
+
if (t.startsWith(this.root + '/')) t = t.slice(this.root.length + 1); // absolute path inside the repository
|
|
158
|
+
let m = /^(.+?\.[A-Za-z0-9]+):(\d+)$/.exec(t);
|
|
159
|
+
if (m) {
|
|
160
|
+
const [, file, line] = m;
|
|
161
|
+
const rows = this.store.all(`SELECT s.id FROM symbols s JOIN files f ON f.id = s.file_id WHERE f.path = ? AND s.start_line <= ? AND s.end_line >= ?
|
|
162
|
+
ORDER BY (s.end_line - s.start_line) ASC LIMIT 1`, file.replace(/^\.\//, ''), Number(line), Number(line));
|
|
163
|
+
return { matches: rows.map(r => this.sym(r.id)), how: 'location' };
|
|
164
|
+
}
|
|
165
|
+
m = /^(.+?\.[A-Za-z0-9]+)[:#](.+)$/.exec(t);
|
|
166
|
+
if (m) {
|
|
167
|
+
const [, file, q] = m;
|
|
168
|
+
const rows = this.store.all(`SELECT s.id FROM symbols s JOIN files f ON f.id = s.file_id WHERE f.path = ? AND (s.qname = ? OR s.name = ? OR s.qname LIKE ?) ORDER BY s.qname = ? DESC, s.start_line LIMIT ?`,
|
|
169
|
+
file.replace(/^\.\//, ''), q, q, '%.' + q, q, limit);
|
|
170
|
+
return { matches: rows.map(r => this.sym(r.id)), how: 'file+name' };
|
|
171
|
+
}
|
|
172
|
+
const norm = t.replace(/::|#|->/g, '.');
|
|
173
|
+
if (norm.includes('.')) {
|
|
174
|
+
const name = norm.split('.').pop();
|
|
175
|
+
const rows = this.store.all(`SELECT id, qname FROM symbols WHERE name = ?`, name).filter(r => r.qname === norm || r.qname.endsWith('.' + norm));
|
|
176
|
+
if (rows.length) return { matches: this.#rank(rows.map(r => r.id)).slice(0, limit).map(id => this.sym(id)), how: 'qualified' };
|
|
177
|
+
}
|
|
178
|
+
let rows = this.store.all('SELECT id FROM symbols WHERE name = ?', norm.split('.').pop());
|
|
179
|
+
if (!rows.length) rows = this.store.all('SELECT id FROM symbols WHERE name_lc = ?', norm.split('.').pop().toLowerCase());
|
|
180
|
+
return { matches: this.#rank(rows.map(r => r.id)).slice(0, limit).map(id => this.sym(id)), how: 'name' };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Order same-name candidates the way a developer means them: library code before tests and
|
|
185
|
+
* example/sample apps, definitions that are used before unused copies, central before peripheral.
|
|
186
|
+
*/
|
|
187
|
+
#rank(ids) {
|
|
188
|
+
const cen = this.centrality();
|
|
189
|
+
const meta = new Map(ids.map(id => [id, this.store.get(`SELECT f.is_test, f.path, s.kind, s.exported, (SELECT COUNT(*) FROM refs r WHERE r.dst_id = s.id) AS nrefs
|
|
190
|
+
FROM symbols s JOIN files f ON f.id = s.file_id WHERE s.id = ?`, id)]));
|
|
191
|
+
const score = (id) => {
|
|
192
|
+
const m = meta.get(id);
|
|
193
|
+
return (cen.get(id) ?? 0) + (m?.is_test ? -1 : 0) + (m && EXAMPLE_PATH.test(m.path) ? -0.8 : 0) + (m?.exported ? 0.2 : 0)
|
|
194
|
+
+ (TYPE_KINDS.has(m?.kind) || CALLABLE.has(m?.kind) ? 0.3 : 0) + Math.log1p(m?.nrefs ?? 0) * 0.1;
|
|
195
|
+
};
|
|
196
|
+
return [...ids].sort((a, b) => score(b) - score(a) || a - b);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
members(id) {
|
|
200
|
+
return this.store.all('SELECT id, name, qname, kind, start_line, end_line, sig, visibility FROM symbols WHERE parent_id = ? ORDER BY start_line', id);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Members declared outside the type body (Go/Rust/C++ receivers, JS prototype assignments). */
|
|
204
|
+
ownedMembers(sym) {
|
|
205
|
+
const t = this.ix.table;
|
|
206
|
+
const ids = t.byOwner.get(sym.name) ?? [];
|
|
207
|
+
const f = t.file(sym.file_id);
|
|
208
|
+
return ids.map(id => t.sym(id)).filter(s => s && s.parentId !== sym.id && (t.file(s.fileId)?.dir === f?.dir || t.file(s.fileId)?.package === f?.package || s.fileId === sym.file_id))
|
|
209
|
+
.map(s => this.store.get('SELECT s.id, s.name, s.qname, s.kind, s.start_line, s.end_line, s.sig, s.visibility, f.path FROM symbols s JOIN files f ON f.id = s.file_id WHERE s.id = ?', s.id));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── references ───────────────────────────────────────────────────────────────
|
|
213
|
+
/** Supertypes (resolved) of the type that declares a member, for dispatch-aware references. */
|
|
214
|
+
#supertypeMembers(sym) {
|
|
215
|
+
const t = this.ix.table, r = this.ix.resolver;
|
|
216
|
+
const type = r.enclosingType(sym.id);
|
|
217
|
+
if (!type) return [];
|
|
218
|
+
const out = [];
|
|
219
|
+
const seen = new Set();
|
|
220
|
+
const visit = (typeSym, depth) => {
|
|
221
|
+
if (!typeSym || depth > 5 || seen.has(typeSym.id)) return;
|
|
222
|
+
seen.add(typeSym.id);
|
|
223
|
+
for (const b of typeSym.bases ?? []) {
|
|
224
|
+
const bt = r.resolveTypeName(b, typeSym.fileId);
|
|
225
|
+
if (!bt) continue;
|
|
226
|
+
for (const mid of this.#membersNamed(bt.id, sym.name)) if (!t.sym(mid)?.isStatic && this.#sameArity(sym, mid)) out.push({ id: mid, via: bt.name });
|
|
227
|
+
visit(bt, depth + 1);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
if (type.id != null) visit(t.sym(type.id), 0);
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The subtypes of a member's type, transitively, split into those that inherit the member — they
|
|
236
|
+
* run it as their own, so a change to it reaches them — and those that redeclare it (their own
|
|
237
|
+
* version runs; their subtypes are not followed).
|
|
238
|
+
* @returns {{ inherit: object[], override: object[] }} index rows of the subtypes
|
|
239
|
+
*/
|
|
240
|
+
inheritance(id, { max = 30 } = {}) {
|
|
241
|
+
const out = { inherit: [], override: [] };
|
|
242
|
+
const sym = this.sym(id);
|
|
243
|
+
if (!sym) return out;
|
|
244
|
+
const type = this.ix.resolver.enclosingType(sym.id);
|
|
245
|
+
if (!type || type.id == null || type.id === sym.id) return out;
|
|
246
|
+
// every subtype, then which version each one runs: the first type of its method resolution
|
|
247
|
+
// order that declares the member (so `class C(B, A)` runs B's override even when reached via A)
|
|
248
|
+
const subs = [];
|
|
249
|
+
const seen = new Set([type.id]);
|
|
250
|
+
let frontier = [type.id];
|
|
251
|
+
for (let depth = 0; depth < 8 && frontier.length && subs.length < 3000; depth++) {
|
|
252
|
+
const next = [];
|
|
253
|
+
for (const tid of frontier) {
|
|
254
|
+
for (const { src_id } of this.store.all("SELECT DISTINCT src_id FROM refs WHERE dst_id = ? AND kind = 'inherit' AND src_id IS NOT NULL", tid)) {
|
|
255
|
+
if (seen.has(src_id)) continue;
|
|
256
|
+
seen.add(src_id);
|
|
257
|
+
subs.push(src_id);
|
|
258
|
+
next.push(src_id);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
frontier = next;
|
|
262
|
+
}
|
|
263
|
+
const declares = new Map();
|
|
264
|
+
const declared = (tid) => { if (!declares.has(tid)) declares.set(tid, this.#membersNamed(tid, sym.name).length > 0); return declares.get(tid); };
|
|
265
|
+
for (const sid of subs) {
|
|
266
|
+
const order = this.ix.resolver.mro(sid);
|
|
267
|
+
if (!order.includes(type.id)) continue;
|
|
268
|
+
const owner = order.find(declared);
|
|
269
|
+
let bucket = null;
|
|
270
|
+
if (owner === type.id) bucket = out.inherit;
|
|
271
|
+
// its own version, where it would otherwise run this one
|
|
272
|
+
else if (owner === sid && order.slice(1).find(declared) === type.id) bucket = out.override;
|
|
273
|
+
if (!bucket) continue;
|
|
274
|
+
const st = this.sym(sid);
|
|
275
|
+
if (!st) continue;
|
|
276
|
+
bucket.push(st);
|
|
277
|
+
if (out.inherit.length + out.override.length >= max) return out;
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Members of a type with a name: declared in its body, or outside it with the type as owner (Go, Rust, C++). */
|
|
283
|
+
/** Where overloads are methods of their own, an override has the parameters of what it overrides. */
|
|
284
|
+
#sameArity(sym, otherId) {
|
|
285
|
+
if (!this.ix.resolver.specs[sym.lang]?.distinctOverloads) return true;
|
|
286
|
+
const o = this.store.get('SELECT name, sig FROM symbols WHERE id = ?', otherId);
|
|
287
|
+
const a = sigArity(sym.sig, sym.name), b = o ? sigArity(o.sig, o.name) : null;
|
|
288
|
+
return !a || !b || (a.min === b.min && a.max === b.max);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#membersNamed(typeId, name) {
|
|
292
|
+
const t = this.ix.table;
|
|
293
|
+
const ty = t.sym(typeId);
|
|
294
|
+
const out = [...(t.byParent.get(typeId)?.get(name) ?? [])];
|
|
295
|
+
if (ty) {
|
|
296
|
+
const dir = path.posix.dirname(t.file(ty.fileId)?.path ?? '');
|
|
297
|
+
for (const id of t.byOwner.get(ty.name) ?? []) {
|
|
298
|
+
const m = t.sym(id);
|
|
299
|
+
if (m && m.name === name && m.parentId !== typeId && path.posix.dirname(t.file(m.fileId)?.path ?? '') === dir && !out.includes(id)) out.push(id);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Overload set: same qualified name, same file, same container (TS/Java/C#/C++ overloads). */
|
|
306
|
+
overloads(sym) {
|
|
307
|
+
// where each overload is a method of its own (Java, C#, Kotlin…), calls are bound to the one they reach
|
|
308
|
+
if (this.ix.resolver.specs[sym.lang]?.distinctOverloads) return [];
|
|
309
|
+
return this.store.all('SELECT id FROM symbols WHERE file_id = ? AND qname = ? AND kind = ? AND (parent_id IS ? OR parent_id = ?) AND is_static = ?',
|
|
310
|
+
sym.file_id, sym.qname, sym.kind, sym.parent_id, sym.parent_id, sym.is_static ? 1 : 0).map(r => r.id);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Is `memberId` one of the members `sym` overrides (declared in a supertype)? */
|
|
314
|
+
#isSupertypeMemberOf(memberId, sym) {
|
|
315
|
+
return this.#supertypeMembers(sym).some(s => s.id === memberId);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Members with the same name in subtypes (overrides/implementations), transitively. */
|
|
319
|
+
#subtypeMembers(sym) {
|
|
320
|
+
const t = this.ix.table, r = this.ix.resolver;
|
|
321
|
+
const type = r.enclosingType(sym.id);
|
|
322
|
+
if (!type || type.id == null) return [];
|
|
323
|
+
const out = [];
|
|
324
|
+
const seen = new Set([type.id]);
|
|
325
|
+
let frontier = [type.id];
|
|
326
|
+
for (let depth = 0; depth < 4 && frontier.length; depth++) {
|
|
327
|
+
const next = [];
|
|
328
|
+
for (const tid of frontier) {
|
|
329
|
+
const subs = this.store.all("SELECT DISTINCT src_id FROM refs WHERE dst_id = ? AND kind = 'inherit' AND src_id IS NOT NULL", tid);
|
|
330
|
+
for (const { src_id } of subs) {
|
|
331
|
+
if (seen.has(src_id)) continue;
|
|
332
|
+
seen.add(src_id);
|
|
333
|
+
next.push(src_id);
|
|
334
|
+
const st = t.sym(src_id);
|
|
335
|
+
for (const mid of this.#membersNamed(src_id, sym.name)) if (!t.sym(mid)?.isStatic && this.#sameArity(sym, mid)) out.push({ id: mid, via: st?.name ?? '?' });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
frontier = next;
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* String literals naming a member in code that works with its class or a subclass —
|
|
345
|
+
* `sinon.stub(obj, 'name')`, `jest.spyOn(x, 'name')`, `getattr(o, "name")`,
|
|
346
|
+
* `patch.object(C, "name")`, `patch("pkg.mod.C.name")`: uses no type checker sees, which a
|
|
347
|
+
* rename has to update too. Test titles (`describe('name', …)`) are left out.
|
|
348
|
+
* @returns {{ path: string, line: number, text: string }[]}
|
|
349
|
+
*/
|
|
350
|
+
stringMentions(id, { max = 20 } = {}) {
|
|
351
|
+
const sym = this.sym(id);
|
|
352
|
+
if (!sym || !['method', 'property', 'field', 'function'].includes(sym.kind) || sym.name.length < 3) return [];
|
|
353
|
+
const files = new Set(this.references(id, { family: true })?.groups.keys() ?? []);
|
|
354
|
+
const type = this.ix.resolver.enclosingType(sym.id);
|
|
355
|
+
if (type?.id != null) {
|
|
356
|
+
const family = new Set([type.id]);
|
|
357
|
+
let frontier = [type.id];
|
|
358
|
+
for (let depth = 0; depth < 4 && frontier.length; depth++) {
|
|
359
|
+
const next = [];
|
|
360
|
+
for (const tid of frontier) {
|
|
361
|
+
for (const { src_id } of this.store.all("SELECT DISTINCT src_id FROM refs WHERE dst_id = ? AND kind = 'inherit' AND src_id IS NOT NULL", tid)) {
|
|
362
|
+
if (!family.has(src_id)) { family.add(src_id); next.push(src_id); }
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
frontier = next;
|
|
366
|
+
}
|
|
367
|
+
const ids = [...family];
|
|
368
|
+
for (const { path: p } of this.store.all(`SELECT DISTINCT f.path FROM refs r JOIN files f ON f.id = r.file_id WHERE r.dst_id IN (${ids.map(() => '?').join(',')})`, ...ids)) files.add(p);
|
|
369
|
+
} else files.add(sym.path);
|
|
370
|
+
const name = sym.name.replace(/[$]/g, '\\$');
|
|
371
|
+
const lit = new RegExp(`(['"\`])(?:[\\w.]*\\.)?${name}\\1`);
|
|
372
|
+
const title = /^\s*(?:describe|it|test|context|suite)(?:\.\w+)?\s*\(/;
|
|
373
|
+
const out = [];
|
|
374
|
+
for (const f of [...files].sort()) {
|
|
375
|
+
const lines = this.fileLines(f) ?? [];
|
|
376
|
+
for (let i = 0; i < lines.length && out.length < max; i++) {
|
|
377
|
+
if (lit.test(lines[i]) && !title.test(lines[i])) out.push({ path: f, line: i + 1, text: lines[i].trim() });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* All references to a symbol, grouped by file. Includes its overload set and, for members,
|
|
385
|
+
* the method family: calls through a supertype (may dispatch here) and calls to overrides in
|
|
386
|
+
* subtypes — each labelled so the agent can tell direct uses from dispatch-related ones.
|
|
387
|
+
* @returns {{ groups: Map<string, object[]>, total: number, unresolvedSameName: number }}
|
|
388
|
+
*/
|
|
389
|
+
references(id, { kinds = null, includeTests = true, minConf = 0, family = true } = {}) {
|
|
390
|
+
const sym = this.sym(id);
|
|
391
|
+
if (!sym) return null;
|
|
392
|
+
const ids = [...new Set([id, ...this.overloads(sym)])];
|
|
393
|
+
const via = new Map();
|
|
394
|
+
if (family && !sym.is_static && (sym.kind === 'method' || sym.kind === 'property' || sym.kind === 'field')) {
|
|
395
|
+
for (const s of this.#supertypeMembers(sym)) if (!ids.includes(s.id)) { ids.push(s.id); via.set(s.id, s.via); }
|
|
396
|
+
for (const s of this.#subtypeMembers(sym)) if (!ids.includes(s.id)) { ids.push(s.id); via.set(s.id, s.via); }
|
|
397
|
+
}
|
|
398
|
+
const rows = this.store.all(`SELECT r.id, r.kind, r.line, r.col, r.conf, r.ncand, r.recv, r.dst_id, f.path, f.is_test, src.qname AS src_qname, src.kind AS src_kind, src.start_line AS src_line
|
|
399
|
+
FROM refs r JOIN files f ON f.id = r.file_id LEFT JOIN symbols src ON src.id = r.src_id
|
|
400
|
+
WHERE r.dst_id IN (${ids.map(() => '?').join(',')}) ORDER BY f.is_test, f.path, r.line`, ...ids); // production code before tests
|
|
401
|
+
const out = [];
|
|
402
|
+
for (const r of rows) {
|
|
403
|
+
if (kinds && !kinds.includes(r.kind)) continue;
|
|
404
|
+
if (!includeTests && r.is_test) continue;
|
|
405
|
+
if (r.conf < minConf) continue;
|
|
406
|
+
// `super().m()` binds statically to the parent's m: it cannot reach an override elsewhere
|
|
407
|
+
if (r.recv === 'super' && via.has(r.dst_id) && this.#isSupertypeMemberOf(r.dst_id, sym)) continue;
|
|
408
|
+
out.push({ ...r, via: via.get(r.dst_id) ?? null, confidence: confidenceLabel(via.has(r.dst_id) ? r.conf * 0.8 : r.conf) });
|
|
409
|
+
}
|
|
410
|
+
const unbound = this.#unboundSameName(sym, ids, { includeTests });
|
|
411
|
+
const unresolved = unbound.total;
|
|
412
|
+
// where the other references with this name went (lets an agent rule them out without grepping)
|
|
413
|
+
const elsewhere = this.store.all(`SELECT d.qname, df.path, COUNT(*) AS n FROM refs r JOIN symbols d ON d.id = r.dst_id JOIN files df ON df.id = d.file_id
|
|
414
|
+
JOIN files f ON f.id = r.file_id WHERE r.name = ? AND r.dst_id NOT IN (${ids.map(() => '?').join(',')})${includeTests ? '' : ' AND f.is_test = 0'}
|
|
415
|
+
GROUP BY r.dst_id ORDER BY n DESC LIMIT 4`, sym.name, ...ids);
|
|
416
|
+
const groups = new Map();
|
|
417
|
+
for (const r of out) (groups.get(r.path) ?? groups.set(r.path, []).get(r.path)).push(r);
|
|
418
|
+
return { sym, groups, total: out.length, unresolvedSameName: unresolved, unbound, elsewhere };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Same-name call sites whose receiver type is unknown, split by plausibility: a call to
|
|
423
|
+
* `Reflector.get` has to obtain a Reflector somewhere, so a site is plausible when its file
|
|
424
|
+
* mentions the declaring type (or a related type) or its receiver is named after it
|
|
425
|
+
* (`this.reflector.get`). The rest are in files that never mention the type.
|
|
426
|
+
*/
|
|
427
|
+
#unboundSameName(sym, ids, { includeTests = true } = {}) {
|
|
428
|
+
const rows = this.store.all(`SELECT r.line, r.recv, f.path FROM refs r JOIN files f ON f.id = r.file_id
|
|
429
|
+
WHERE r.name = ? AND r.dst_id IS NULL AND r.kind IN ('call','new','decorator')${includeTests ? '' : ' AND f.is_test = 0'} ORDER BY f.is_test, f.path, r.line`, sym.name);
|
|
430
|
+
if (!rows.length) return { total: 0, plausible: [], typeNames: [] };
|
|
431
|
+
const typeNames = new Set();
|
|
432
|
+
const owner = sym.parent_id != null ? this.sym(sym.parent_id) : null;
|
|
433
|
+
// a type is mentioned by its name; an anonymous class (`<anonymous>`) never is
|
|
434
|
+
const named = (n) => n && !n.startsWith('<anonymous');
|
|
435
|
+
if (owner && TYPE_KINDS.has(owner.kind)) {
|
|
436
|
+
if (named(owner.name)) typeNames.add(owner.name);
|
|
437
|
+
// subclasses that inherit the member without redeclaring it: `jsonSocket.send()` on a
|
|
438
|
+
// JsonSocket extends TcpSocket runs TcpSocket.send
|
|
439
|
+
if (!sym.is_static) for (const name of this.#inheritingSubtypes(owner.id, sym.name)) if (named(name)) typeNames.add(name);
|
|
440
|
+
}
|
|
441
|
+
for (const id of ids) {
|
|
442
|
+
const s = id === sym.id ? null : this.sym(id);
|
|
443
|
+
const o = s?.parent_id != null ? this.sym(s.parent_id) : null;
|
|
444
|
+
if (o && TYPE_KINDS.has(o.kind) && named(o.name)) typeNames.add(o.name);
|
|
445
|
+
}
|
|
446
|
+
if (!typeNames.size) {
|
|
447
|
+
// a free function: plausible where its file is imported, or next to it
|
|
448
|
+
const dir = path.posix.dirname(sym.path);
|
|
449
|
+
const importers = new Set(this.store.all('SELECT DISTINCT f.path FROM imports i JOIN files f ON f.id = i.file_id WHERE i.target_file_id = ?', sym.file_id).map(r => r.path));
|
|
450
|
+
const plausible = rows.filter(r => importers.has(r.path) || path.posix.dirname(r.path) === dir);
|
|
451
|
+
return { total: rows.length, plausible, typeNames: [] };
|
|
452
|
+
}
|
|
453
|
+
const names = [...typeNames];
|
|
454
|
+
const wordRe = new RegExp(`\\b(${names.map(n => n.replace(/[$]/g, '\\$')).join('|')})\\b`);
|
|
455
|
+
const lower = names.map(n => n.toLowerCase());
|
|
456
|
+
const mentions = new Map();
|
|
457
|
+
const plausible = rows.filter(r => {
|
|
458
|
+
const recv = (r.recv || '').toLowerCase();
|
|
459
|
+
if (recv && lower.some(n => recv.includes(n))) return true;
|
|
460
|
+
if (!mentions.has(r.path)) {
|
|
461
|
+
const lines = this.fileLines(r.path);
|
|
462
|
+
mentions.set(r.path, !!lines && lines.some(l => wordRe.test(l)));
|
|
463
|
+
}
|
|
464
|
+
return mentions.get(r.path);
|
|
465
|
+
});
|
|
466
|
+
return { total: rows.length, plausible, typeNames: names };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Names of the subtypes of a type (transitively) that inherit its member `name` without redeclaring it. */
|
|
470
|
+
#inheritingSubtypes(typeId, name, max = 20) {
|
|
471
|
+
const t = this.ix.table;
|
|
472
|
+
const out = [], seen = new Set([typeId]);
|
|
473
|
+
let frontier = [typeId];
|
|
474
|
+
for (let depth = 0; depth < 4 && frontier.length && out.length < max; depth++) {
|
|
475
|
+
const next = [];
|
|
476
|
+
for (const tid of frontier) {
|
|
477
|
+
for (const { src_id } of this.store.all("SELECT DISTINCT src_id FROM refs WHERE dst_id = ? AND kind = 'inherit' AND src_id IS NOT NULL", tid)) {
|
|
478
|
+
if (seen.has(src_id)) continue;
|
|
479
|
+
seen.add(src_id);
|
|
480
|
+
if (this.#membersNamed(src_id, name).length) continue; // overrides it: its calls bind to its own member
|
|
481
|
+
const st = t.sym(src_id);
|
|
482
|
+
if (st && out.length < max) { out.push(st.name); next.push(src_id); }
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
frontier = next;
|
|
486
|
+
}
|
|
487
|
+
return out;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Outgoing references of a symbol (what it calls/uses), including nested closures. */
|
|
491
|
+
callees(id, { kinds = ['call', 'new'] } = {}) {
|
|
492
|
+
const sym = this.sym(id);
|
|
493
|
+
if (!sym) return [];
|
|
494
|
+
const scope = [id, ...this.store.all(`SELECT id FROM symbols WHERE file_id = ? AND start_line >= ? AND end_line <= ? AND id != ? AND kind IN ('function','method')`, sym.file_id, sym.start_line, sym.end_line, id).map(r => r.id)];
|
|
495
|
+
const rows = this.store.all(`SELECT r.kind, r.line, r.name, r.recv, r.conf, r.ncand, r.dst_id, d.qname AS dst_qname, d.kind AS dst_kind, df.path AS dst_path, d.start_line AS dst_line
|
|
496
|
+
FROM refs r LEFT JOIN symbols d ON d.id = r.dst_id LEFT JOIN files df ON df.id = d.file_id
|
|
497
|
+
WHERE r.src_id IN (${scope.map(() => '?').join(',')}) AND r.kind IN (${kinds.map(() => '?').join(',')}) ORDER BY r.line`, ...scope, ...kinds);
|
|
498
|
+
return rows;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Transitive callers/dependents (BFS over reverse edges), confidence multiplied along paths. */
|
|
502
|
+
dependents(startIds, { depth = 3, maxNodes = 300, minConf = 0.3, kinds = DEP_KINDS } = {}) {
|
|
503
|
+
const seen = new Map(); // id -> { depth, conf, via, kind, sites }
|
|
504
|
+
const fileLevel = new Map(); // path -> { depth, conf, via, sites } for module-level references
|
|
505
|
+
const overflow = new Set(); // dependents beyond maxNodes: counted, not expanded
|
|
506
|
+
let frontier = [];
|
|
507
|
+
for (const id of startIds) {
|
|
508
|
+
seen.set(id, { depth: 0, conf: 1, via: null });
|
|
509
|
+
frontier.push({ id, conf: 1 });
|
|
510
|
+
}
|
|
511
|
+
// overrides / implementations of a changed method must keep matching its signature
|
|
512
|
+
for (const id of startIds) {
|
|
513
|
+
const sym = this.sym(id);
|
|
514
|
+
if (!sym || sym.is_static || sym.kind !== 'method') continue;
|
|
515
|
+
for (const m of this.#subtypeMembers(sym)) {
|
|
516
|
+
if (seen.has(m.id)) continue;
|
|
517
|
+
seen.set(m.id, { depth: 1, conf: 0.9, via: id, kind: 'override', sites: [] });
|
|
518
|
+
frontier.push({ id: m.id, conf: 0.9 });
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
for (let d = 1; d <= depth && frontier.length; d++) {
|
|
522
|
+
const next = [];
|
|
523
|
+
for (const { id, conf } of frontier) {
|
|
524
|
+
const sym = this.sym(id);
|
|
525
|
+
const targets = [id];
|
|
526
|
+
if (sym && !sym.is_static && (sym.kind === 'method' || sym.kind === 'property')) for (const s of this.#supertypeMembers(sym)) targets.push(s.id);
|
|
527
|
+
// a constructor is invoked through its class: `new Foo()` / `Foo()` (Python)
|
|
528
|
+
if (isConstructor(sym) && sym.parent_id != null) targets.push(sym.parent_id);
|
|
529
|
+
const rows = this.store.all(`SELECT r.src_id, r.conf, r.kind, r.line, r.dst_id, r.recv, f.path, f.is_test FROM refs r JOIN files f ON f.id = r.file_id
|
|
530
|
+
WHERE r.dst_id IN (${targets.map(() => '?').join(',')}) AND r.kind IN (${kinds.map(() => '?').join(',')})`, ...targets, ...kinds);
|
|
531
|
+
const rank = (r) => (EXAMPLE_PATH.test(r.path) ? 2 : r.is_test ? 1 : 0);
|
|
532
|
+
rows.sort((a, b) => rank(a) - rank(b));
|
|
533
|
+
for (const r of rows) {
|
|
534
|
+
// through the class only its instantiations reach the constructor
|
|
535
|
+
if (r.dst_id !== id && isConstructor(sym) && r.dst_id === sym.parent_id && r.kind !== 'call' && r.kind !== 'new') continue;
|
|
536
|
+
// `super().m()` runs exactly the parent's m, never this override (it is static dispatch)
|
|
537
|
+
if (r.dst_id !== id && r.recv === 'super') continue;
|
|
538
|
+
const c = conf * r.conf;
|
|
539
|
+
if (c < minConf) continue;
|
|
540
|
+
if (r.src_id == null) {
|
|
541
|
+
const prev = fileLevel.get(r.path);
|
|
542
|
+
if (!prev) fileLevel.set(r.path, { depth: d, conf: c, via: id, sites: [r.line] });
|
|
543
|
+
else { if (prev.conf < c) Object.assign(prev, { depth: d, conf: c, via: id }); if (prev.sites.length < 20) prev.sites.push(r.line); }
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
const prev = seen.get(r.src_id);
|
|
547
|
+
if (prev) {
|
|
548
|
+
if (prev.via === id && prev.sites && prev.sites.length < 20) prev.sites.push({ path: r.path, line: r.line });
|
|
549
|
+
if (prev.conf >= c) continue;
|
|
550
|
+
}
|
|
551
|
+
if (!prev && seen.size >= maxNodes + startIds.length) { overflow.add(r.src_id); continue; } // count, don't expand
|
|
552
|
+
seen.set(r.src_id, { depth: prev ? Math.min(prev.depth, d) : d, conf: c, via: id, kind: r.kind, sites: prev?.via === id ? prev.sites : [{ path: r.path, line: r.line }] });
|
|
553
|
+
if (!prev) next.push({ id: r.src_id, conf: c });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
frontier = next;
|
|
557
|
+
}
|
|
558
|
+
for (const s of startIds) seen.delete(s);
|
|
559
|
+
return { nodes: seen, fileLevel, truncated: overflow.size > 0, overflow: overflow.size };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Members this method overrides/implements (up) and members overriding it (down). */
|
|
563
|
+
methodFamily(sym) {
|
|
564
|
+
const load = (list) => list.map(x => this.sym(x.id)).filter(Boolean);
|
|
565
|
+
return { up: load(this.#supertypeMembers(sym)), down: load(this.#subtypeMembers(sym)) };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Unbound same-name call sites that may be uses of a symbol (see #unboundSameName). */
|
|
569
|
+
unboundFor(id, opts = {}) {
|
|
570
|
+
const sym = this.sym(id);
|
|
571
|
+
if (!sym) return { total: 0, plausible: [], typeNames: [] };
|
|
572
|
+
return this.#unboundSameName(sym, [id, ...this.overloads(sym)], opts);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ── impact ───────────────────────────────────────────────────────────────────
|
|
576
|
+
/** Symbols overlapping the working-tree diff (git), as impact seeds. */
|
|
577
|
+
diffSeeds(base = 'HEAD') {
|
|
578
|
+
const r = spawnSync('git', ['diff', '-U0', '--no-color', base, '--'], { cwd: this.root, encoding: 'utf8', maxBuffer: 1 << 28 });
|
|
579
|
+
if (r.status !== 0) return { error: (r.stderr || 'git diff failed').trim(), seeds: [], files: [] };
|
|
580
|
+
const changes = new Map();
|
|
581
|
+
let file = null;
|
|
582
|
+
for (const line of r.stdout.split('\n')) {
|
|
583
|
+
if (line.startsWith('+++ ')) { file = line.slice(4).replace(/^b\//, ''); if (file === '/dev/null') file = null; continue; }
|
|
584
|
+
const h = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
585
|
+
if (h && file) {
|
|
586
|
+
const start = Number(h[1]), len = h[2] === undefined ? 1 : Number(h[2]);
|
|
587
|
+
(changes.get(file) ?? changes.set(file, []).get(file)).push([start, Math.max(start, start + len - 1)]);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
const untracked = spawnSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd: this.root, encoding: 'utf8' });
|
|
591
|
+
for (const f of (untracked.stdout || '').split('\n').filter(Boolean)) if (!changes.has(f)) changes.set(f, [[1, 1e9]]);
|
|
592
|
+
const seeds = new Set();
|
|
593
|
+
for (const [f, ranges] of changes) {
|
|
594
|
+
for (const [a, b] of ranges) {
|
|
595
|
+
const rows = this.store.all(`SELECT s.id, s.start_line, s.end_line, s.kind FROM symbols s JOIN files fl ON fl.id = s.file_id WHERE fl.path = ? AND s.start_line <= ? AND s.end_line >= ?`, f, b, a);
|
|
596
|
+
// innermost symbols only
|
|
597
|
+
const inner = rows.filter(x => !rows.some(y => y.id !== x.id && y.start_line >= x.start_line && y.end_line <= x.end_line && (y.end_line - y.start_line) < (x.end_line - x.start_line)));
|
|
598
|
+
for (const x of inner) seeds.add(x.id);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return { seeds: [...seeds], files: [...changes.keys()] };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Files that historically change together with the given files (git log, bounded). */
|
|
605
|
+
coChange(files, { commits = 400, limit = 8 } = {}) {
|
|
606
|
+
if (!this._cochange || this._cochangeAt < Date.now() - 600_000) {
|
|
607
|
+
const r = spawnSync('git', ['log', `-n${commits}`, '--name-only', '--pretty=format:%x00'], { cwd: this.root, encoding: 'utf8', maxBuffer: 1 << 28 });
|
|
608
|
+
const counts = new Map(); const pair = new Map();
|
|
609
|
+
if (r.status === 0) {
|
|
610
|
+
for (const block of r.stdout.split('\0')) {
|
|
611
|
+
const fs_ = [...new Set(block.split('\n').map(s => s.trim()).filter(Boolean))];
|
|
612
|
+
if (fs_.length < 2 || fs_.length > 40) { for (const f of fs_) counts.set(f, (counts.get(f) ?? 0) + 1); continue; }
|
|
613
|
+
for (const f of fs_) counts.set(f, (counts.get(f) ?? 0) + 1);
|
|
614
|
+
for (const a of fs_) for (const b of fs_) if (a !== b) { const k = a + '\0' + b; pair.set(k, (pair.get(k) ?? 0) + 1); }
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
this._cochange = { counts, pair };
|
|
618
|
+
this._cochangeAt = Date.now();
|
|
619
|
+
}
|
|
620
|
+
const { counts, pair } = this._cochange;
|
|
621
|
+
const agg = new Map();
|
|
622
|
+
for (const f of files) {
|
|
623
|
+
const n = counts.get(f) ?? 0;
|
|
624
|
+
if (!n) continue;
|
|
625
|
+
for (const [k, v] of pair) {
|
|
626
|
+
if (!k.startsWith(f + '\0')) continue;
|
|
627
|
+
const other = k.slice(f.length + 1);
|
|
628
|
+
if (files.includes(other) || v < 2) continue;
|
|
629
|
+
const score = v / n;
|
|
630
|
+
const prev = agg.get(other);
|
|
631
|
+
if (!prev || prev.score < score) agg.set(other, { file: other, together: v, of: n, score });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return [...agg.values()].sort((a, b) => b.score - a.score || b.together - a.together).slice(0, limit);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Tests related to files by naming convention (foo.ts ↔ foo.test.ts / test_foo.py / foo_test.go). */
|
|
638
|
+
conventionTests(files) {
|
|
639
|
+
const out = new Set();
|
|
640
|
+
for (const f of files) {
|
|
641
|
+
const base = path.posix.basename(f).replace(/\.[^.]+$/, '');
|
|
642
|
+
const stem = base.replace(/\.(test|spec)$/, '');
|
|
643
|
+
const rows = this.store.all(`SELECT path FROM files WHERE is_test = 1 AND (path LIKE ? OR path LIKE ? OR path LIKE ? OR path LIKE ? OR path LIKE ?)`,
|
|
644
|
+
`%/${stem}.test.%`, `%/${stem}.spec.%`, `%test_${stem}.%`, `%/${stem}_test.%`, `%/${stem}Test.%`);
|
|
645
|
+
for (const r of rows) out.add(r.path);
|
|
646
|
+
}
|
|
647
|
+
return [...out];
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ── maps ─────────────────────────────────────────────────────────────────────
|
|
651
|
+
/** Symbols ranked by (personalized) centrality, grouped by file. */
|
|
652
|
+
rankedSymbols({ focusIds = null, pathPrefix = null, limit = 400 } = {}) {
|
|
653
|
+
let scores;
|
|
654
|
+
if (focusIds?.length) {
|
|
655
|
+
const pers = new Map(focusIds.map(id => [id, 1]));
|
|
656
|
+
scores = computeCentrality(this.store, { personalization: pers, graph: loadGraph(this.store) });
|
|
657
|
+
} else scores = this.centrality();
|
|
658
|
+
const rows = this.store.all(`SELECT s.id, s.name, s.qname, s.kind, s.parent_id, s.start_line, s.end_line, s.sig, s.exported, f.path, f.is_test
|
|
659
|
+
FROM symbols s JOIN files f ON f.id = s.file_id WHERE s.kind NOT IN ('variable','field','property','constant','selector','keyframes')`);
|
|
660
|
+
const filtered = rows.filter(r => !r.is_test && (!pathPrefix || r.path.startsWith(pathPrefix)));
|
|
661
|
+
filtered.sort((a, b) => (scores.get(b.id) ?? 0) - (scores.get(a.id) ?? 0) || a.id - b.id);
|
|
662
|
+
return filtered.slice(0, limit).map(r => ({ ...r, score: scores.get(r.id) ?? 0 }));
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
stats() {
|
|
666
|
+
const c = (sql) => this.store.get(sql)?.n ?? 0;
|
|
667
|
+
const langs = this.store.all('SELECT lang, COUNT(*) AS n FROM files GROUP BY lang ORDER BY n DESC');
|
|
668
|
+
return {
|
|
669
|
+
root: this.root,
|
|
670
|
+
files: c('SELECT COUNT(*) n FROM files'),
|
|
671
|
+
symbols: c('SELECT COUNT(*) n FROM symbols'),
|
|
672
|
+
refs: c('SELECT COUNT(*) n FROM refs'),
|
|
673
|
+
resolved: c('SELECT COUNT(*) n FROM refs WHERE dst_id IS NOT NULL'),
|
|
674
|
+
exact: c('SELECT COUNT(*) n FROM refs WHERE conf >= 0.9'),
|
|
675
|
+
langs,
|
|
676
|
+
lastSync: this.lastSyncStats,
|
|
677
|
+
watcher: this.watcherHealthy,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
}
|