graph-indexer 3.0.0 → 3.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/CHANGELOG.md +456 -0
- package/README.md +72 -15
- package/SECURITY.md +21 -4
- package/bin/graph-indexer.mjs +46 -5
- package/integrations/claude-code/.claude-plugin/plugin.json +3 -2
- package/integrations/claude-code/agents/code-structure.md +0 -3
- package/integrations/claude-code/skills/setup/SKILL.md +26 -0
- package/integrations/claude-code-local/.claude-plugin/plugin.json +9 -0
- package/integrations/{claude-code → claude-code-local}/.mcp.json +1 -1
- package/integrations/claude-code-local/README.md +10 -0
- package/integrations/claude-code-local/agents/code-structure.md +22 -0
- package/integrations/{claude-code → claude-code-local}/hooks/gi-hook.mjs +12 -6
- package/integrations/opencode/graph-indexer.js +13 -8
- package/package.json +2 -2
- package/src/cli/helper.mjs +15 -1
- package/src/cli/hook.mjs +140 -48
- package/src/cli/init.mjs +49 -17
- package/src/cli/resident.mjs +15 -5
- package/src/index/discover.mjs +3 -5
- package/src/index/indexer.mjs +77 -41
- package/src/index/modules.mjs +82 -7
- package/src/index/parse-worker.mjs +12 -0
- package/src/index/parsepool.mjs +73 -0
- package/src/index/resolver.mjs +371 -25
- package/src/mcp/server.mjs +16 -12
- package/src/mcp/tools.mjs +419 -26
- package/src/parse/arity.mjs +41 -3
- package/src/parse/extract.mjs +277 -46
- package/src/parse/lang/ecma.mjs +2 -0
- package/src/parse/lang/go.mjs +2 -0
- package/src/parse/lang/jvm.mjs +33 -0
- package/src/parse/lang/markdown.mjs +92 -0
- package/src/parse/lang/python.mjs +43 -0
- package/src/parse/lang/rust.mjs +215 -15
- package/src/parse/lang/sfc.mjs +125 -0
- package/src/parse/languages.mjs +9 -2
- package/src/query/check.mjs +55 -17
- package/src/query/facts.mjs +3 -2
- package/src/query/injection-spring.mjs +405 -0
- package/src/query/injection.mjs +173 -0
- package/src/query/intel.mjs +87 -22
- package/src/query/issue.mjs +156 -0
- package/src/query/orm.mjs +286 -0
- package/src/query/read.mjs +6 -1
- package/src/query/report.mjs +53 -0
- package/src/query/routes.mjs +142 -0
- package/src/query/showcase.mjs +59 -0
- package/src/query/testcmd.mjs +25 -14
- package/src/query/textsearch.mjs +29 -11
- package/src/search/search.mjs +85 -17
- package/src/search/semantic.mjs +201 -0
- package/src/search/vectors.mjs +122 -0
- package/src/store/db.mjs +22 -4
- package/src/util/git.mjs +30 -0
- package/src/util/paths.mjs +40 -1
- package/src/util/regex.mjs +2 -0
- package/vendor/grammars/README.md +47 -0
- package/vendor/grammars/kotlin.wasm.gz +0 -0
- package/vendor/grammars/manifest.json +4 -4
- /package/integrations/{claude-code → claude-code-local}/hooks/hooks.json +0 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency injection: the edge between a value a class receives and the code that provides it,
|
|
3
|
+
* which the compiler does not see. In NestJS a constructor parameter marked `@Inject(TOKEN)` gets
|
|
4
|
+
* whatever a module registered as `{ provide: TOKEN, useClass | useExisting | useFactory | useValue }`;
|
|
5
|
+
* its declared type is often an interface or a union, so neither the type checker nor grep for the
|
|
6
|
+
* type name leads to the implementation.
|
|
7
|
+
*
|
|
8
|
+
* Recognised from the indexed source (TypeScript):
|
|
9
|
+
* - providers: an object with `provide:` and one of `useClass`, `useExisting`, `useFactory`,
|
|
10
|
+
* `useValue`, on one line or over the lines that follow;
|
|
11
|
+
* - injection points: `@Inject(TOKEN)` on a constructor parameter or property.
|
|
12
|
+
* Test files are left out: their providers are mocks.
|
|
13
|
+
* A token is the definition the index binds it to (a constant, a class, a symbol); a string, a
|
|
14
|
+
* constant declared as that string and `Class.name` are one token. An expression such as
|
|
15
|
+
* `getRepositoryToken(User)` is matched by its text, only when written the same way, and marked so;
|
|
16
|
+
* tokens computed at run time (`Object.values(Namespace).map(ns => ({ provide: ns, … }))`) are not
|
|
17
|
+
* found. Microservice clients registered with `ClientsModule.register([{ name: TOKEN }])` are
|
|
18
|
+
* providers too.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { collectSpring } from './injection-spring.mjs';
|
|
22
|
+
|
|
23
|
+
const PROVIDE = /\bprovide\s*:\s*([^,}\n]+?)\s*(?:,|}|$)/;
|
|
24
|
+
const USE = /\buse(Class|Existing|Factory|Value)\s*:\s*([^,}\n]*)/;
|
|
25
|
+
const INJECT = /@Inject\(\s*([^)]*?)\s*\)/g;
|
|
26
|
+
const PARAM = /^\s*(?:(?:private|public|protected|readonly|override)\s+)*([A-Za-z_$][\w$]*)\s*[?!]?\s*:\s*([^,=)]+)/;
|
|
27
|
+
|
|
28
|
+
const strip = (s) => String(s ?? '').trim().replace(/\s+/g, ' ');
|
|
29
|
+
|
|
30
|
+
/** The definition a name on a line is bound to, through the index. */
|
|
31
|
+
function bound(intel, fileId, line, name) {
|
|
32
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(name)) return null;
|
|
33
|
+
const r = intel.store.get('SELECT dst_id FROM refs WHERE file_id = ? AND line = ? AND name = ? AND dst_id IS NOT NULL LIMIT 1', fileId, line, name);
|
|
34
|
+
return r ? symRow(intel, r.dst_id) : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function symRow(intel, id) {
|
|
38
|
+
return intel.store.get('SELECT s.id, s.name, s.qname, s.kind, s.start_line, s.end_line, s.parent_id, f.path FROM symbols s JOIN files f ON f.id = s.file_id WHERE s.id = ?', id) ?? null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A constant's string value (`const REFLECTOR = 'Reflector'`), if it is declared as one. */
|
|
42
|
+
function stringValue(intel, s) {
|
|
43
|
+
if (s.kind !== 'constant' && s.kind !== 'variable') return null;
|
|
44
|
+
const l = intel.fileLines(s.path)?.[s.start_line - 1] ?? '';
|
|
45
|
+
return new RegExp(`\\b${s.name}\\s*(?::[^=]+)?=\\s*(['"\`])([^'"\`]*)\\1\\s*;?\\s*$`).exec(l)?.[2] ?? null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A token's identity: the definition it names, or its text (strings and expressions). A string,
|
|
50
|
+
* a constant holding one and `Class.name` are the same token when the strings are equal; `p.provide`
|
|
51
|
+
* is the token of the provider declared as `p`.
|
|
52
|
+
*/
|
|
53
|
+
function tokenKey(intel, fileId, line, text) {
|
|
54
|
+
const t = strip(text);
|
|
55
|
+
const dotted = /^([\w$]+)\.(name|provide)$/.exec(t);
|
|
56
|
+
if (dotted) {
|
|
57
|
+
const head = bound(intel, fileId, line, dotted[1]);
|
|
58
|
+
if (head && dotted[2] === 'name' && head.kind === 'class') return { key: `str:${head.name}`, token: t, sym: head, exact: true };
|
|
59
|
+
if (head && dotted[2] === 'provide') return { key: `holder:${head.id}`, token: t, sym: head, exact: true };
|
|
60
|
+
}
|
|
61
|
+
const last = /^(?:[\w$]+\.)*([\w$]+)$/.exec(t)?.[1];
|
|
62
|
+
let s = last ? bound(intel, fileId, line, last) : null;
|
|
63
|
+
// a member of a type (`FactoryProvider.provide`) is a shape, not a token
|
|
64
|
+
if (s && (s.kind === 'field' || s.kind === 'property')) s = null;
|
|
65
|
+
if (s) {
|
|
66
|
+
const v = stringValue(intel, s);
|
|
67
|
+
return { key: v != null ? `str:${v}` : `id:${s.id}`, token: s.qname, sym: s, exact: true };
|
|
68
|
+
}
|
|
69
|
+
const lit = /^(['"`])(.*)\1$/.exec(t);
|
|
70
|
+
return { key: lit ? `str:${lit[2]}` : `text:${t}`, token: t, sym: null, exact: !!lit };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The variable or constant a line is declared in (`const catsProvider = { provide: … }`). */
|
|
74
|
+
function holderAt(intel, fileId, line) {
|
|
75
|
+
return intel.store.get(`SELECT id FROM symbols WHERE file_id = ? AND kind IN ('variable', 'constant') AND start_line <= ? AND end_line >= ? ORDER BY start_line DESC LIMIT 1`, fileId, line, line)?.id ?? null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Is the line inside an interface or a type alias? */
|
|
79
|
+
function inTypeDeclaration(intel, fileId, line) {
|
|
80
|
+
const s = intel.store.get(`SELECT kind FROM symbols WHERE file_id = ? AND start_line <= ? AND end_line >= ? AND kind IN ('class', 'interface', 'type', 'function', 'method', 'variable', 'constant')
|
|
81
|
+
ORDER BY start_line DESC LIMIT 1`, fileId, line, line);
|
|
82
|
+
return s?.kind === 'interface' || s?.kind === 'type';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The innermost class around a line. */
|
|
86
|
+
function classAt(intel, fileId, line) {
|
|
87
|
+
return intel.store.get(`SELECT s.id, s.name, s.qname, s.kind, s.start_line, s.end_line, s.parent_id, f.path FROM symbols s JOIN files f ON f.id = s.file_id
|
|
88
|
+
WHERE s.file_id = ? AND s.kind = 'class' AND s.start_line <= ? AND s.end_line >= ? ORDER BY s.start_line DESC LIMIT 1`, fileId, line, line) ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Providers and injection points of the repository, keyed by what is asked for (a NestJS token, a
|
|
93
|
+
* Spring type): { providers: [{ key, token, exact, how, target, targetText, file, line }],
|
|
94
|
+
* injections: [{ key, token, exact, owner, param, type, file, line, from, why? }] }, `from`
|
|
95
|
+
* being the providers the point may receive (Spring: the beans chosen, see injection-spring.mjs).
|
|
96
|
+
*/
|
|
97
|
+
export function collectInjection(intel) {
|
|
98
|
+
if (intel._injection?.version === intel.ix?.version) return intel._injection.data;
|
|
99
|
+
const providers = [], injections = [];
|
|
100
|
+
// test modules register mocks (`{ provide: Service, useValue: { get: jest.fn() } }`): not the app's wiring
|
|
101
|
+
const files = intel.store.all(`SELECT id, path FROM files WHERE lang IN ('typescript', 'tsx') AND is_test = 0`);
|
|
102
|
+
for (const f of files) {
|
|
103
|
+
const lines = intel.fileLines(f.path);
|
|
104
|
+
if (!lines) continue;
|
|
105
|
+
const text = lines.join('\n');
|
|
106
|
+
if (!text.includes('provide') && !text.includes('@Inject(') && !text.includes('ClientsModule')) continue;
|
|
107
|
+
lines.forEach((l, i) => {
|
|
108
|
+
const line = i + 1;
|
|
109
|
+
if (/^\s*(\/\/|\/?\*)/.test(l)) return;
|
|
110
|
+
const p = PROVIDE.exec(l);
|
|
111
|
+
// `provide: InjectionToken;` in an interface or type declares the shape, it provides nothing
|
|
112
|
+
if (p && !inTypeDeclaration(intel, f.id, line)) {
|
|
113
|
+
// the `use…` of this provider: on its line, or on the lines below before the next one
|
|
114
|
+
let u = USE.exec(l.slice(p.index)), at = line;
|
|
115
|
+
for (let j = i + 1; !u && j < Math.min(lines.length, i + 15) && !PROVIDE.test(lines[j]); j++) { u = USE.exec(lines[j]); at = j + 1; }
|
|
116
|
+
if (u) {
|
|
117
|
+
const tk = tokenKey(intel, f.id, line, p[1]);
|
|
118
|
+
const targetText = strip(u[2]) || `(${u[1] === 'Value' ? 'a value' : 'code'} on the lines below)`;
|
|
119
|
+
const name = /^(?:new\s+)?(?:[\w$]+\.)*([\w$]+)\b/.exec(targetText)?.[1];
|
|
120
|
+
let target = name && !/^(async|function)$/.test(name) && !/=>/.test(targetText.split('(')[0]) ? bound(intel, f.id, at, name) : null;
|
|
121
|
+
// `useClass: options.useClass` passes along what a caller gave: not a definition here
|
|
122
|
+
if (target && (target.kind === 'field' || target.kind === 'property')) target = null;
|
|
123
|
+
providers.push({ ...tk, how: `use${u[1]}`, target, targetText, file: f.path, line, holder: holderAt(intel, f.id, line) });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// a microservice client: `ClientsModule.register([{ name: MATH_SERVICE, transport: … }])`
|
|
127
|
+
const client = /^\s*name\s*:\s*([^,}\n]+?)\s*(?:,|}|$)/.exec(l) ?? /\{\s*name\s*:\s*([^,}\n]+?)\s*[,}]/.exec(l);
|
|
128
|
+
if (client && lines.slice(Math.max(0, i - 6), i + 1).some(x => /\bClientsModule\.register(Async)?\s*\(/.test(x))) {
|
|
129
|
+
providers.push({ ...tokenKey(intel, f.id, line, client[1]), how: 'ClientsModule', target: null, targetText: 'a client proxy for the microservice', file: f.path, line, holder: null });
|
|
130
|
+
}
|
|
131
|
+
for (const m of l.matchAll(INJECT)) {
|
|
132
|
+
if (!m[1]) continue;
|
|
133
|
+
const tk = tokenKey(intel, f.id, line, m[1]);
|
|
134
|
+
// the parameter or property it decorates: the rest of the line, or the next line
|
|
135
|
+
const rest = l.slice(m.index + m[0].length);
|
|
136
|
+
const pm = PARAM.exec(rest.trim() ? rest : (lines[i + 1] ?? ''));
|
|
137
|
+
injections.push({ ...tk, owner: classAt(intel, f.id, line), param: pm?.[1] ?? null, type: pm ? strip(pm[2]).replace(/;$/, '') : null, file: f.path, line });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
// `@Inject(p.provide)`: the token of the provider declared as `p`
|
|
142
|
+
for (const j of injections) {
|
|
143
|
+
if (!j.key.startsWith('holder:')) continue;
|
|
144
|
+
const p = providers.find(x => x.holder === Number(j.key.slice(7)));
|
|
145
|
+
if (p) Object.assign(j, { key: p.key, token: `${j.token} (${p.token})` });
|
|
146
|
+
}
|
|
147
|
+
for (const j of injections) j.from = providers.filter(p => p.key === j.key);
|
|
148
|
+
const spring = collectSpring(intel);
|
|
149
|
+
const data = { providers: [...providers, ...spring.providers], injections: [...injections, ...spring.injections] };
|
|
150
|
+
intel._injection = { version: intel.ix?.version, data };
|
|
151
|
+
return data;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** For a definition: the tokens it provides, each with where it is injected. */
|
|
155
|
+
export function providedBy(intel, id) {
|
|
156
|
+
const { providers, injections } = collectInjection(intel);
|
|
157
|
+
return providers.filter(p => p.target?.id === id).map(p => ({ ...p, into: injections.filter(j => j.from.includes(p)) }));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** For a token or a type asked for by injection: what provides it and where it is injected. */
|
|
161
|
+
export function tokenUses(intel, id) {
|
|
162
|
+
const { providers, injections } = collectInjection(intel);
|
|
163
|
+
const key = `id:${id}`;
|
|
164
|
+
const js = injections.filter(j => j.key === key);
|
|
165
|
+
// a NestJS token's providers name it; a Spring type's are the beans its injection points receive
|
|
166
|
+
const ps = [...new Set([...providers.filter(p => p.key === key && !p.types), ...js.flatMap(j => j.from)])];
|
|
167
|
+
return { providers: ps, injections: js };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** What a class receives by injection (`@Inject(TOKEN)`, Spring's injection points), each with its providers. */
|
|
171
|
+
export function injectedInto(intel, classId) {
|
|
172
|
+
return collectInjection(intel).injections.filter(j => j.owner?.id === classId);
|
|
173
|
+
}
|
package/src/query/intel.mjs
CHANGED
|
@@ -7,15 +7,16 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import { spawnSync } from 'node:child_process';
|
|
11
10
|
import { Store } from '../store/db.mjs';
|
|
12
11
|
import { Indexer } from '../index/indexer.mjs';
|
|
13
12
|
import { SearchEngine } from '../search/search.mjs';
|
|
14
13
|
import { computeCentrality, loadGraph } from '../index/graph.mjs';
|
|
15
14
|
import { confidenceLabel } from '../index/resolver.mjs';
|
|
16
|
-
import { dataDir } from '../util/paths.mjs';
|
|
15
|
+
import { dataDir, withinRoot, isRevision } from '../util/paths.mjs';
|
|
17
16
|
import { isConstructor } from '../parse/languages.mjs';
|
|
18
17
|
import { sigArity } from '../parse/arity.mjs';
|
|
18
|
+
import { escapeRegExp } from '../util/regex.mjs';
|
|
19
|
+
import { gitSync } from '../util/git.mjs';
|
|
19
20
|
|
|
20
21
|
const TYPE_KINDS = new Set(['class', 'interface', 'struct', 'enum', 'trait', 'type', 'object', 'module', 'impl']);
|
|
21
22
|
const CALLABLE = new Set(['function', 'method', 'constructor', 'macro']);
|
|
@@ -128,6 +129,7 @@ export class CodeIntel {
|
|
|
128
129
|
|
|
129
130
|
// ── source access ────────────────────────────────────────────────────────────
|
|
130
131
|
fileLines(rel) {
|
|
132
|
+
if (!withinRoot(this.root, rel)) return null;
|
|
131
133
|
const abs = path.join(this.root, rel);
|
|
132
134
|
let st;
|
|
133
135
|
try { st = fs.statSync(abs); } catch { return null; }
|
|
@@ -162,7 +164,7 @@ export class CodeIntel {
|
|
|
162
164
|
ORDER BY (s.end_line - s.start_line) ASC LIMIT 1`, file.replace(/^\.\//, ''), Number(line), Number(line));
|
|
163
165
|
return { matches: rows.map(r => this.sym(r.id)), how: 'location' };
|
|
164
166
|
}
|
|
165
|
-
m = /^(
|
|
167
|
+
m = /^([^:#]+?\.[A-Za-z0-9]+)[:#](.+)$/.exec(t);
|
|
166
168
|
if (m) {
|
|
167
169
|
const [, file, q] = m;
|
|
168
170
|
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 ?`,
|
|
@@ -171,9 +173,18 @@ export class CodeIntel {
|
|
|
171
173
|
}
|
|
172
174
|
const norm = t.replace(/::|#|->/g, '.');
|
|
173
175
|
if (norm.includes('.')) {
|
|
174
|
-
const
|
|
175
|
-
const rows = this.store.all(`SELECT id, qname FROM symbols
|
|
176
|
-
|
|
176
|
+
const qual = norm.split('.'), name = qual.pop();
|
|
177
|
+
const rows = this.store.all(`SELECT s.id, s.qname, f.path FROM symbols s JOIN files f ON f.id = s.file_id WHERE s.name = ?`, name);
|
|
178
|
+
const exact = rows.filter(r => r.qname === norm || r.qname.endsWith('.' + norm));
|
|
179
|
+
if (exact.length) return { matches: this.#rank(exact.map(r => r.id)).slice(0, limit).map(id => this.sym(id)), how: 'qualified' };
|
|
180
|
+
// `module.func`, `pkg.mod.func`, `ns::Type::method`, `Foo.prototype.bar`: the owner named
|
|
181
|
+
// last before the name is one of the definition's enclosing names, its file or a directory
|
|
182
|
+
const owner = qual[qual.length - 1];
|
|
183
|
+
const inModule = rows.filter(r => r.qname.split('.').slice(0, -1).includes(owner) || r.path.replace(/\.[^./]+$/, '').split('/').includes(owner));
|
|
184
|
+
if (inModule.length) return { matches: this.#rank(inModule.map(r => r.id)).slice(0, limit).map(id => this.sym(id)), how: 'module' };
|
|
185
|
+
// the qualifier names nothing that declares `name`: answering with another owner's
|
|
186
|
+
// `name` would present the wrong definition's uses as the asked one's
|
|
187
|
+
if (rows.length) return { matches: [], how: 'qualifier-unknown', sameName: this.#rank(rows.map(r => r.id)).slice(0, limit).map(id => this.sym(id)) };
|
|
177
188
|
}
|
|
178
189
|
let rows = this.store.all('SELECT id FROM symbols WHERE name = ?', norm.split('.').pop());
|
|
179
190
|
if (!rows.length) rows = this.store.all('SELECT id FROM symbols WHERE name_lc = ?', norm.split('.').pop().toLowerCase());
|
|
@@ -196,6 +207,32 @@ export class CodeIntel {
|
|
|
196
207
|
return [...ids].sort((a, b) => score(b) - score(a) || a - b);
|
|
197
208
|
}
|
|
198
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Where `word` occurs in code the parser could not read (an ERROR region, give or take a
|
|
212
|
+
* line): a declaration or a use of it there may be missing from the index or bound wrongly.
|
|
213
|
+
* The first such line per file.
|
|
214
|
+
* @returns {{ path: string, line: number }[]}
|
|
215
|
+
*/
|
|
216
|
+
filesWithParseErrors(word, { lang = null, max = 50 } = {}) {
|
|
217
|
+
if (!word || !/^[\w$]+$/.test(word)) return [];
|
|
218
|
+
const re = new RegExp(`(?<![\\w$])${escapeRegExp(word)}(?![\\w$])`);
|
|
219
|
+
const rows = this.store.all(`SELECT path, parse_error_lines FROM files WHERE parse_errors > 0${lang ? ' AND lang = ?' : ''} ORDER BY is_test, path LIMIT 5000`, ...(lang ? [lang] : []));
|
|
220
|
+
const out = [];
|
|
221
|
+
for (const r of rows) {
|
|
222
|
+
let regions;
|
|
223
|
+
try { regions = JSON.parse(r.parse_error_lines ?? '[]'); } catch { regions = []; }
|
|
224
|
+
const lines = regions.length ? this.fileLines(r.path) : null;
|
|
225
|
+
if (!lines) continue;
|
|
226
|
+
hit: for (const [a, b] of regions) {
|
|
227
|
+
for (let ln = Math.max(1, a - 1); ln <= Math.min(lines.length, b + 1); ln++) {
|
|
228
|
+
if (re.test(lines[ln - 1])) { out.push({ path: r.path, line: ln }); break hit; }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (out.length >= max) break;
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
199
236
|
members(id) {
|
|
200
237
|
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
238
|
}
|
|
@@ -233,12 +270,14 @@ export class CodeIntel {
|
|
|
233
270
|
|
|
234
271
|
/**
|
|
235
272
|
* 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 —
|
|
237
|
-
* version
|
|
238
|
-
*
|
|
273
|
+
* run it as their own, so a change to it reaches them — those that extend it (they redeclare it,
|
|
274
|
+
* but their version uses this one: `TRANSFORMS = {**Base.TRANSFORMS, …}`, `super().m()`; a change
|
|
275
|
+
* still reaches them, and their subtypes that inherit their version), and those that override it
|
|
276
|
+
* (their own version runs without this one).
|
|
277
|
+
* @returns {{ inherit: object[], extend: object[], override: object[] }} index rows of the subtypes
|
|
239
278
|
*/
|
|
240
279
|
inheritance(id, { max = 30 } = {}) {
|
|
241
|
-
const out = { inherit: [], override: [] };
|
|
280
|
+
const out = { inherit: [], extend: [], override: [] };
|
|
242
281
|
const sym = this.sym(id);
|
|
243
282
|
if (!sym) return out;
|
|
244
283
|
const type = this.ix.resolver.enclosingType(sym.id);
|
|
@@ -261,20 +300,30 @@ export class CodeIntel {
|
|
|
261
300
|
frontier = next;
|
|
262
301
|
}
|
|
263
302
|
const declares = new Map();
|
|
264
|
-
const declared = (tid) => { if (!declares.has(tid)) declares.set(tid, this.#membersNamed(tid, sym.name)
|
|
303
|
+
const declared = (tid) => { if (!declares.has(tid)) declares.set(tid, this.#membersNamed(tid, sym.name)); return declares.get(tid).length > 0; };
|
|
304
|
+
// a redeclaration that reads the member of a type after it in the resolution order uses that version
|
|
305
|
+
const usesNext = (tid, rest) => {
|
|
306
|
+
const later = new Set(rest.flatMap(t => declared(t) ? declares.get(t) : []));
|
|
307
|
+
return declares.get(tid).some(id => this.store.all('SELECT dst_id FROM refs WHERE src_id = ? AND dst_id IS NOT NULL', id).some(r => later.has(r.dst_id)));
|
|
308
|
+
};
|
|
265
309
|
for (const sid of subs) {
|
|
266
310
|
const order = this.ix.resolver.mro(sid);
|
|
267
311
|
if (!order.includes(type.id)) continue;
|
|
268
|
-
const
|
|
312
|
+
const declarers = order.filter(declared);
|
|
313
|
+
const owner = declarers[0];
|
|
314
|
+
// follow the redeclarations that use the next version down to this one
|
|
315
|
+
let k = 0;
|
|
316
|
+
while (declarers[k] !== type.id && k + 1 < declarers.length && usesNext(declarers[k], order.slice(order.indexOf(declarers[k]) + 1))) k++;
|
|
269
317
|
let bucket = null;
|
|
270
318
|
if (owner === type.id) bucket = out.inherit;
|
|
319
|
+
else if (declarers[k] === type.id) bucket = out.extend;
|
|
271
320
|
// its own version, where it would otherwise run this one
|
|
272
|
-
else if (owner === sid &&
|
|
321
|
+
else if (owner === sid && declarers[1] === type.id) bucket = out.override;
|
|
273
322
|
if (!bucket) continue;
|
|
274
323
|
const st = this.sym(sid);
|
|
275
324
|
if (!st) continue;
|
|
276
325
|
bucket.push(st);
|
|
277
|
-
if (out.inherit.length + out.override.length >= max) return out;
|
|
326
|
+
if (out.inherit.length + out.extend.length + out.override.length >= max) return out;
|
|
278
327
|
}
|
|
279
328
|
return out;
|
|
280
329
|
}
|
|
@@ -306,8 +355,23 @@ export class CodeIntel {
|
|
|
306
355
|
overloads(sym) {
|
|
307
356
|
// where each overload is a method of its own (Java, C#, Kotlin…), calls are bound to the one they reach
|
|
308
357
|
if (this.ix.resolver.specs[sym.lang]?.distinctOverloads) return [];
|
|
309
|
-
|
|
310
|
-
|
|
358
|
+
// methods of one name from impls of different traits or types (Rust `Debug::fmt` / `Display::fmt`,
|
|
359
|
+
// `impl AsyncWrite for Cursor<Vec<u8>>` / `… for Cursor<&mut [u8]>`) are not redeclarations
|
|
360
|
+
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 = ? AND (kind != \'method\' OR (bases IS ? AND (? != \'rust\' OR decorators IS ?)))',
|
|
361
|
+
sym.file_id, sym.qname, sym.kind, sym.parent_id, sym.parent_id, sym.is_static ? 1 : 0, sym.bases ?? null, sym.lang, sym.decorators ?? null).map(r => r.id);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* A Rust trait method's implementations (`fn m` in each `impl Trait for X`): calls of them are
|
|
366
|
+
* calls of the trait's method, as rust-analyzer counts them, so they are its references.
|
|
367
|
+
*/
|
|
368
|
+
#traitImpls(sym) {
|
|
369
|
+
if (sym.lang !== 'rust' || sym.kind !== 'method' || sym.parent_id == null) return [];
|
|
370
|
+
const trait = this.sym(sym.parent_id);
|
|
371
|
+
if (trait?.kind !== 'trait') return [];
|
|
372
|
+
const r = this.ix.resolver;
|
|
373
|
+
return this.store.all("SELECT s.id, s.file_id FROM symbols s JOIN files f ON f.id = s.file_id WHERE s.name = ? AND s.kind = 'method' AND f.lang = 'rust' AND s.bases LIKE ?", sym.name, `%"${trait.name}"%`)
|
|
374
|
+
.filter(m => m.id !== sym.id && r.resolveTypeName(trait.name, m.file_id)?.id === trait.id).map(m => m.id);
|
|
311
375
|
}
|
|
312
376
|
|
|
313
377
|
/** Is `memberId` one of the members `sym` overrides (declared in a supertype)? */
|
|
@@ -367,7 +431,7 @@ export class CodeIntel {
|
|
|
367
431
|
const ids = [...family];
|
|
368
432
|
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
433
|
} else files.add(sym.path);
|
|
370
|
-
const name = sym.name
|
|
434
|
+
const name = escapeRegExp(sym.name);
|
|
371
435
|
const lit = new RegExp(`(['"\`])(?:[\\w.]*\\.)?${name}\\1`);
|
|
372
436
|
const title = /^\s*(?:describe|it|test|context|suite)(?:\.\w+)?\s*\(/;
|
|
373
437
|
const out = [];
|
|
@@ -389,7 +453,7 @@ export class CodeIntel {
|
|
|
389
453
|
references(id, { kinds = null, includeTests = true, minConf = 0, family = true } = {}) {
|
|
390
454
|
const sym = this.sym(id);
|
|
391
455
|
if (!sym) return null;
|
|
392
|
-
const ids = [...new Set([id, ...this.overloads(sym)])];
|
|
456
|
+
const ids = [...new Set([id, ...this.overloads(sym), ...this.#traitImpls(sym)])];
|
|
393
457
|
const via = new Map();
|
|
394
458
|
if (family && !sym.is_static && (sym.kind === 'method' || sym.kind === 'property' || sym.kind === 'field')) {
|
|
395
459
|
for (const s of this.#supertypeMembers(sym)) if (!ids.includes(s.id)) { ids.push(s.id); via.set(s.id, s.via); }
|
|
@@ -451,7 +515,7 @@ export class CodeIntel {
|
|
|
451
515
|
return { total: rows.length, plausible, typeNames: [] };
|
|
452
516
|
}
|
|
453
517
|
const names = [...typeNames];
|
|
454
|
-
const wordRe = new RegExp(`\\b(${names.map(n => n
|
|
518
|
+
const wordRe = new RegExp(`\\b(${names.map(n => escapeRegExp(n)).join('|')})\\b`);
|
|
455
519
|
const lower = names.map(n => n.toLowerCase());
|
|
456
520
|
const mentions = new Map();
|
|
457
521
|
const plausible = rows.filter(r => {
|
|
@@ -575,7 +639,8 @@ export class CodeIntel {
|
|
|
575
639
|
// ── impact ───────────────────────────────────────────────────────────────────
|
|
576
640
|
/** Symbols overlapping the working-tree diff (git), as impact seeds. */
|
|
577
641
|
diffSeeds(base = 'HEAD') {
|
|
578
|
-
|
|
642
|
+
if (!isRevision(base)) return { error: `Not a git revision: ${base}`, seeds: [], files: [] };
|
|
643
|
+
const r = gitSync(this.root, ['diff', '-U0', '--no-color', base, '--'], { maxBuffer: 1 << 28 });
|
|
579
644
|
if (r.status !== 0) return { error: (r.stderr || 'git diff failed').trim(), seeds: [], files: [] };
|
|
580
645
|
const changes = new Map();
|
|
581
646
|
let file = null;
|
|
@@ -587,7 +652,7 @@ export class CodeIntel {
|
|
|
587
652
|
(changes.get(file) ?? changes.set(file, []).get(file)).push([start, Math.max(start, start + len - 1)]);
|
|
588
653
|
}
|
|
589
654
|
}
|
|
590
|
-
const untracked =
|
|
655
|
+
const untracked = gitSync(this.root, ['ls-files', '--others', '--exclude-standard']);
|
|
591
656
|
for (const f of (untracked.stdout || '').split('\n').filter(Boolean)) if (!changes.has(f)) changes.set(f, [[1, 1e9]]);
|
|
592
657
|
const seeds = new Set();
|
|
593
658
|
for (const [f, ranges] of changes) {
|
|
@@ -604,7 +669,7 @@ export class CodeIntel {
|
|
|
604
669
|
/** Files that historically change together with the given files (git log, bounded). */
|
|
605
670
|
coChange(files, { commits = 400, limit = 8 } = {}) {
|
|
606
671
|
if (!this._cochange || this._cochangeAt < Date.now() - 600_000) {
|
|
607
|
-
const r =
|
|
672
|
+
const r = gitSync(this.root, ['log', `-n${commits}`, '--name-only', '--pretty=format:%x00'], { maxBuffer: 1 << 28 });
|
|
608
673
|
const counts = new Map(); const pair = new Map();
|
|
609
674
|
if (r.status === 0) {
|
|
610
675
|
for (const block of r.stdout.split('\0')) {
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where an issue points in the code. An issue names things in code spans, examples and tracebacks
|
|
3
|
+
* (`DATE_DIFF`, `rich_club_coefficient(normalized=True)`, `exp.Agg`); the code to change is where
|
|
4
|
+
* several of those names meet. Each name is matched against identifiers by their words, so
|
|
5
|
+
* `DATE_DIFF`, `DateDiff` and `_date_diff_sql` are one term, and each definition scores the
|
|
6
|
+
* rarity (inverse document frequency) of the distinct terms its lines carry, plus the terms its
|
|
7
|
+
* own name or its file's path is made of. Library code only: tests are left out.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
// words of examples that name the language, not the code under it
|
|
13
|
+
const NOISE = new Set(('true false none null undefined self cls this def class return import from print len str int float bool list dict set tuple range '
|
|
14
|
+
+ 'isinstance assert lambda and or not in is if else elif for while with as pass raise try except finally yield async await new let const var '
|
|
15
|
+
+ 'function public private static void string object type repr').split(' '));
|
|
16
|
+
const IDENT = /[A-Za-z_][A-Za-z0-9_]*/g;
|
|
17
|
+
const WORD = /[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+/g;
|
|
18
|
+
const MAX_FILE_BYTES = 1_000_000;
|
|
19
|
+
// examples, benchmarks and docs are not where an issue gets fixed
|
|
20
|
+
const ASIDE_PATH = /(^|\/)(examples?|samples?|demos?|sandbox|playground|docs?(_src|_source|_examples?)?|documentation|benchmarks?|bench|scripts?|fixtures?|testdata|tutorials?|website|\.github)\//i;
|
|
21
|
+
// measured on the dev split of bench/agentic/localize.mjs: mild length normalisation (BM25's b),
|
|
22
|
+
// the title's terms four times an example's, tables and lists of names at 0.6 of a definition
|
|
23
|
+
const B = 0.2;
|
|
24
|
+
const TITLE_W = 4;
|
|
25
|
+
const VALUE_W = 0.6;
|
|
26
|
+
const VALUE_KINDS = new Set(['field', 'property', 'variable', 'constant']);
|
|
27
|
+
|
|
28
|
+
const partsCache = new Map();
|
|
29
|
+
function parts(id) {
|
|
30
|
+
let p = partsCache.get(id);
|
|
31
|
+
if (!p) {
|
|
32
|
+
p = (id.match(WORD) ?? []).map(w => w.toLowerCase());
|
|
33
|
+
if (partsCache.size > 200_000) partsCache.clear();
|
|
34
|
+
partsCache.set(id, p);
|
|
35
|
+
}
|
|
36
|
+
return p;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Whether an issue reads like one: long enough, or carrying code. */
|
|
40
|
+
export function isIssueText(text) {
|
|
41
|
+
const t = String(text ?? '');
|
|
42
|
+
return t.length >= 240 || /```/.test(t) || (t.match(/`[^`\n]+`/g) ?? []).length >= 3;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The code-like names an issue uses: from its code spans and blocks, and identifier-shaped words of its prose. */
|
|
46
|
+
export function issueTerms(text) {
|
|
47
|
+
const t = String(text ?? '');
|
|
48
|
+
const code = [];
|
|
49
|
+
// a fence's language tag (```python) is not part of the code
|
|
50
|
+
const prose = t.replace(/```[^\n]*\n([\s\S]*?)```/g, (_, m) => { code.push(m); return ' '; })
|
|
51
|
+
.replace(/`([^`\n]+)`/g, (_, m) => { code.push(m); return ' '; });
|
|
52
|
+
// the title names what the issue is about; examples name everything they touch
|
|
53
|
+
const title = new Set((t.trim().split('\n')[0].match(IDENT) ?? []).map(x => parts(x).join('_')));
|
|
54
|
+
const ids = [];
|
|
55
|
+
for (const c of code) for (const m of c.matchAll(IDENT)) ids.push(m[0]);
|
|
56
|
+
// prose words that can only be code: snake_case, camelCase, dotted paths
|
|
57
|
+
for (const m of prose.matchAll(/[A-Za-z_][\w]*(?:\.[A-Za-z_]\w*)+|\b[a-z]+_[a-z_\d]+\b|\b[a-z]+[A-Z]\w*\b|\b[A-Z][a-z]+[A-Z]\w*\b/g)) for (const x of m[0].split('.')) ids.push(x);
|
|
58
|
+
const terms = new Map();
|
|
59
|
+
for (const id of ids) {
|
|
60
|
+
const p = parts(id);
|
|
61
|
+
if (!p.length || NOISE.has(id.toLowerCase())) continue;
|
|
62
|
+
if (p.length === 1 && p[0].length < 3) continue;
|
|
63
|
+
if (p.every(w => /^\d+$/.test(w))) continue;
|
|
64
|
+
const key = p.join('_');
|
|
65
|
+
if (!terms.has(key)) terms.set(key, { key, parts: p, forms: new Set(), title: title.has(key) });
|
|
66
|
+
terms.get(key).forms.add(id);
|
|
67
|
+
}
|
|
68
|
+
return [...terms.values()];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Definitions ranked by the issue's terms they carry. Returns [{ id, score, terms }] (best first).
|
|
73
|
+
* @param {import('./intel.mjs').CodeIntel} intel
|
|
74
|
+
*/
|
|
75
|
+
export function issueSeeds(intel, text, { limit = 12, path: prefix = null } = {}) {
|
|
76
|
+
const terms = issueTerms(text);
|
|
77
|
+
if (!terms.length) return [];
|
|
78
|
+
const byFirst = new Map();
|
|
79
|
+
for (const t of terms) { if (!byFirst.has(t.parts[0])) byFirst.set(t.parts[0], []); byFirst.get(t.parts[0]).push(t); }
|
|
80
|
+
const files = intel.store.all('SELECT id, path, size FROM files WHERE is_test = 0');
|
|
81
|
+
const hits = new Map(); // symbol id -> Set(term key)
|
|
82
|
+
const owned = new Map(); // symbol id -> lines it owns (its own, not its members')
|
|
83
|
+
const df = new Map(); // term key -> number of definitions carrying it
|
|
84
|
+
let total = 0;
|
|
85
|
+
for (const f of files) {
|
|
86
|
+
if (prefix && !f.path.startsWith(prefix)) continue;
|
|
87
|
+
if (f.size > MAX_FILE_BYTES) continue;
|
|
88
|
+
if (ASIDE_PATH.test(f.path) && !(prefix && ASIDE_PATH.test(prefix))) continue;
|
|
89
|
+
const syms = intel.store.all('SELECT id, start_line, end_line FROM symbols WHERE file_id = ? ORDER BY start_line', f.id);
|
|
90
|
+
total += syms.length;
|
|
91
|
+
if (!syms.length) continue;
|
|
92
|
+
let text;
|
|
93
|
+
try { text = fs.readFileSync(path.join(intel.root, f.path), 'utf8'); } catch { continue; }
|
|
94
|
+
const lines = text.split('\n');
|
|
95
|
+
// innermost definition of each line
|
|
96
|
+
const owner = new Int32Array(lines.length + 2).fill(-1);
|
|
97
|
+
const span = new Int32Array(lines.length + 2).fill(1 << 30);
|
|
98
|
+
for (const s of syms) {
|
|
99
|
+
const w = s.end_line - s.start_line;
|
|
100
|
+
for (let l = s.start_line; l <= Math.min(s.end_line, lines.length); l++) if (w < span[l]) { span[l] = w; owner[l] = s.id; }
|
|
101
|
+
}
|
|
102
|
+
for (let i = 0; i < lines.length; i++) {
|
|
103
|
+
const o = owner[i + 1];
|
|
104
|
+
if (o < 0) continue;
|
|
105
|
+
owned.set(o, (owned.get(o) ?? 0) + 1);
|
|
106
|
+
for (const m of lines[i].matchAll(IDENT)) {
|
|
107
|
+
const p = parts(m[0]);
|
|
108
|
+
for (let k = 0; k < p.length; k++) {
|
|
109
|
+
const cands = byFirst.get(p[k]);
|
|
110
|
+
if (!cands) continue;
|
|
111
|
+
for (const t of cands) {
|
|
112
|
+
if (k + t.parts.length > p.length) continue;
|
|
113
|
+
let ok = true;
|
|
114
|
+
for (let j = 1; j < t.parts.length; j++) if (p[k + j] !== t.parts[j]) { ok = false; break; }
|
|
115
|
+
if (!ok) continue;
|
|
116
|
+
let set = hits.get(o);
|
|
117
|
+
if (!set) hits.set(o, (set = new Set()));
|
|
118
|
+
if (!set.has(t.key)) { set.add(t.key); df.set(t.key, (df.get(t.key) ?? 0) + 1); }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!hits.size) return [];
|
|
125
|
+
const idf = (k) => Math.log(1 + total / (df.get(k) ?? total));
|
|
126
|
+
const rows = new Map();
|
|
127
|
+
const ids = [...hits.keys()];
|
|
128
|
+
for (let i = 0; i < ids.length; i += 500) {
|
|
129
|
+
const chunk = ids.slice(i, i + 500);
|
|
130
|
+
for (const r of intel.store.all(`SELECT s.id, s.name, s.kind, s.start_line, s.end_line, f.path FROM symbols s JOIN files f ON f.id = s.file_id WHERE s.id IN (${chunk.map(() => '?').join(',')})`, ...chunk)) rows.set(r.id, r);
|
|
131
|
+
}
|
|
132
|
+
// longer definitions carry more terms by chance: BM25's length normalisation, over owned lines
|
|
133
|
+
let sumLen = 0;
|
|
134
|
+
for (const v of owned.values()) sumLen += v;
|
|
135
|
+
const avgLen = sumLen / Math.max(1, owned.size);
|
|
136
|
+
const byKey = new Map(terms.map(t => [t.key, t]));
|
|
137
|
+
const scored = [];
|
|
138
|
+
for (const [id, set] of hits) {
|
|
139
|
+
const r = rows.get(id);
|
|
140
|
+
if (!r) continue;
|
|
141
|
+
let score = 0;
|
|
142
|
+
for (const k of set) score += idf(k) * (byKey.get(k)?.title ? TITLE_W : 1);
|
|
143
|
+
// its own name is a term the issue uses: the issue names this definition
|
|
144
|
+
const own = parts(r.name).join('_');
|
|
145
|
+
if (set.has(own)) score += idf(own);
|
|
146
|
+
// its file's path is made of a term (a dialect, a format, a module named in the issue)
|
|
147
|
+
const pathWords = new Set(r.path.split(/[/._-]+/).flatMap(parts));
|
|
148
|
+
for (const t of terms) if (t.parts.length === 1 && pathWords.has(t.parts[0]) && !set.has(t.key)) score += 0.5 * idf(t.key);
|
|
149
|
+
score /= 1 - B + B * ((owned.get(id) ?? 1) / avgLen);
|
|
150
|
+
// a table or list of names (`__all__`, a function map) enumerates terms rather than combining them
|
|
151
|
+
if (VALUE_KINDS.has(r.kind)) score *= VALUE_W;
|
|
152
|
+
scored.push({ id, score, terms: [...set] });
|
|
153
|
+
}
|
|
154
|
+
scored.sort((a, b) => b.score - a.score || a.id - b.id);
|
|
155
|
+
return scored.slice(0, limit);
|
|
156
|
+
}
|