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.
Files changed (60) hide show
  1. package/CHANGELOG.md +456 -0
  2. package/README.md +72 -15
  3. package/SECURITY.md +21 -4
  4. package/bin/graph-indexer.mjs +46 -5
  5. package/integrations/claude-code/.claude-plugin/plugin.json +3 -2
  6. package/integrations/claude-code/agents/code-structure.md +0 -3
  7. package/integrations/claude-code/skills/setup/SKILL.md +26 -0
  8. package/integrations/claude-code-local/.claude-plugin/plugin.json +9 -0
  9. package/integrations/{claude-code → claude-code-local}/.mcp.json +1 -1
  10. package/integrations/claude-code-local/README.md +10 -0
  11. package/integrations/claude-code-local/agents/code-structure.md +22 -0
  12. package/integrations/{claude-code → claude-code-local}/hooks/gi-hook.mjs +12 -6
  13. package/integrations/opencode/graph-indexer.js +13 -8
  14. package/package.json +2 -2
  15. package/src/cli/helper.mjs +15 -1
  16. package/src/cli/hook.mjs +140 -48
  17. package/src/cli/init.mjs +49 -17
  18. package/src/cli/resident.mjs +15 -5
  19. package/src/index/discover.mjs +3 -5
  20. package/src/index/indexer.mjs +77 -41
  21. package/src/index/modules.mjs +82 -7
  22. package/src/index/parse-worker.mjs +12 -0
  23. package/src/index/parsepool.mjs +73 -0
  24. package/src/index/resolver.mjs +371 -25
  25. package/src/mcp/server.mjs +16 -12
  26. package/src/mcp/tools.mjs +419 -26
  27. package/src/parse/arity.mjs +41 -3
  28. package/src/parse/extract.mjs +277 -46
  29. package/src/parse/lang/ecma.mjs +2 -0
  30. package/src/parse/lang/go.mjs +2 -0
  31. package/src/parse/lang/jvm.mjs +33 -0
  32. package/src/parse/lang/markdown.mjs +92 -0
  33. package/src/parse/lang/python.mjs +43 -0
  34. package/src/parse/lang/rust.mjs +215 -15
  35. package/src/parse/lang/sfc.mjs +125 -0
  36. package/src/parse/languages.mjs +9 -2
  37. package/src/query/check.mjs +55 -17
  38. package/src/query/facts.mjs +3 -2
  39. package/src/query/injection-spring.mjs +405 -0
  40. package/src/query/injection.mjs +173 -0
  41. package/src/query/intel.mjs +87 -22
  42. package/src/query/issue.mjs +156 -0
  43. package/src/query/orm.mjs +286 -0
  44. package/src/query/read.mjs +6 -1
  45. package/src/query/report.mjs +53 -0
  46. package/src/query/routes.mjs +142 -0
  47. package/src/query/showcase.mjs +59 -0
  48. package/src/query/testcmd.mjs +25 -14
  49. package/src/query/textsearch.mjs +29 -11
  50. package/src/search/search.mjs +85 -17
  51. package/src/search/semantic.mjs +201 -0
  52. package/src/search/vectors.mjs +122 -0
  53. package/src/store/db.mjs +22 -4
  54. package/src/util/git.mjs +30 -0
  55. package/src/util/paths.mjs +40 -1
  56. package/src/util/regex.mjs +2 -0
  57. package/vendor/grammars/README.md +47 -0
  58. package/vendor/grammars/kotlin.wasm.gz +0 -0
  59. package/vendor/grammars/manifest.json +4 -4
  60. /package/integrations/{claude-code → claude-code-local}/hooks/hooks.json +0 -0
@@ -12,8 +12,15 @@ import { rust } from './lang/rust.mjs';
12
12
  import { c, cpp } from './lang/native.mjs';
13
13
  import { ruby, php, bash } from './lang/scripting.mjs';
14
14
  import { scala, css } from './lang/misc.mjs';
15
+ import { vue, svelte } from './lang/sfc.mjs';
16
+ import { markdown } from './lang/markdown.mjs';
15
17
 
16
- export const LANGUAGES = [javascript, typescript, tsx, python, go, java, kotlin, scala, csharp, rust, c, cpp, ruby, php, bash, css];
18
+ export const LANGUAGES = [javascript, typescript, tsx, vue, svelte, python, go, java, kotlin, scala, csharp, rust, c, cpp, ruby, php, bash, css, markdown];
19
+
20
+ /** A code language (parsed with a grammar), not documentation. */
21
+ export function isCodeSpec(spec) { return !!spec && !spec.docs; }
22
+ /** The spec of a code file at this path, or null (documentation and unknown files). */
23
+ export function codeSpecForPath(p) { const s = specForPath(p); return s && !s.docs ? s : null; }
17
24
 
18
25
  const byExt = new Map();
19
26
  for (const spec of LANGUAGES) for (const ext of spec.extensions) byExt.set(ext, spec);
@@ -33,7 +40,7 @@ export function languageById(id) { return LANGUAGES.find(l => l.id === id) ?? nu
33
40
  // constructors named by the language rather than declared as such: a method called `initialize` is
34
41
  // Ruby's constructor (`Foo.new`) but an ordinary method in TypeScript or Python
35
42
  const CONSTRUCTOR_NAMES = {
36
- javascript: ['constructor'], typescript: ['constructor'], tsx: ['constructor'],
43
+ javascript: ['constructor'], typescript: ['constructor'], tsx: ['constructor'], vue: ['constructor'], svelte: ['constructor'],
37
44
  python: ['__init__', '__new__'], ruby: ['initialize'], php: ['__construct'],
38
45
  };
39
46
 
@@ -9,21 +9,27 @@
9
9
  * removed definitions that were deleted or renamed but are still used or imported
10
10
  * untouched callers of a changed signature in files the change did not touch (to review)
11
11
  * inherited subclasses that inherit a changed member (they run the new code) or override it
12
- * tests the test files that exercise the changed code, and the command to run them
12
+ * tests the test files that exercise the changed code, and the command to run them;
13
+ * then, apart, the test files that name a changed module in a string (dialect,
14
+ * plugin or backend lookups such as `read="mysql"`, which no reference shows)
13
15
  */
14
16
  import fs from 'node:fs';
15
17
  import path from 'node:path';
16
18
  import { spawnSync } from 'node:child_process';
17
- import { specForPath, isConstructor } from '../parse/languages.mjs';
18
- import { prepareLanguage, extractFile } from '../parse/extract.mjs';
19
+ import { codeSpecForPath as specForPath, isConstructor } from '../parse/languages.mjs'; // documentation is not checked
20
+ import { prepareLanguage, extractFile, parseSource } from '../parse/extract.mjs';
19
21
  import { paramArity, callAt, callArgc, fits, describeArity } from '../parse/arity.mjs';
20
22
  import { testCommands, testCaseCommands } from './testcmd.mjs';
23
+ import { escapeRegExp } from '../util/regex.mjs';
24
+ import { readRepoFile, isRevision } from '../util/paths.mjs';
25
+ import { MAX_FILE_BYTES } from '../index/discover.mjs';
26
+ import { gitSync } from '../util/git.mjs';
21
27
 
22
28
  const CALLABLE = new Set(['function', 'method', 'constructor']);
23
29
  const MEMBER_KINDS = new Set(['class', 'interface', 'struct', 'trait', 'enum', 'object', 'impl', 'module', 'type']);
24
30
 
25
31
  function git(root, args) {
26
- const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 1 << 28 });
32
+ const r = gitSync(root, args, { maxBuffer: 1 << 28 });
27
33
  return r.status === 0 ? r.stdout : null;
28
34
  }
29
35
 
@@ -62,7 +68,7 @@ function changedFiles(root, base) {
62
68
 
63
69
  /** Parse a source text: tree + extracted symbols, each symbol with its definition node's arity. */
64
70
  async function analyse(spec, source, rel, tree = null) {
65
- if (!tree) { const { parser } = await prepareLanguage(spec); tree = parser.parse(source); }
71
+ if (!tree) { const { parser } = await prepareLanguage(spec); tree = parseSource(spec, parser, source); }
66
72
  const ex = await extractFile(spec, source, rel);
67
73
  for (const s of ex.symbols) {
68
74
  if (!CALLABLE.has(s.kind) && !(s.kind === 'field' && /=>|function/.test(s.sig ?? ''))) continue;
@@ -141,15 +147,41 @@ function unionFits(argc, arities) {
141
147
  return list.some(r => fits(argc, r));
142
148
  }
143
149
 
144
- export async function checkChanges(intel, { base = 'HEAD', files = null, tests = true } = {}) {
150
+ const GENERIC_STEMS = new Set(['__init__', 'index', 'main', 'mod', 'lib', 'core', 'base', 'utils', 'util', 'helpers', 'common', 'types']);
151
+
152
+ /**
153
+ * Test files that mention a changed source module's name as a string literal ("mysql" for
154
+ * generators/mysql.py): code reached by a registry lookup — a dialect, plugin or backend chosen by
155
+ * name — that no reference shows. A second tier: broader than the files the index links to.
156
+ */
157
+ function testsNamingModules(intel, files) {
158
+ const stems = new Set();
159
+ for (const f of files) {
160
+ if (intel.store.get('SELECT is_test FROM files WHERE path = ?', f)?.is_test) continue;
161
+ const stem = path.posix.basename(f).replace(/\.[^.]+$/, '');
162
+ if (stem.length >= 3 && !GENERIC_STEMS.has(stem) && /^\w+$/.test(stem)) stems.add(stem);
163
+ }
164
+ if (!stems.size) return [];
165
+ const re = new RegExp(`["'](${[...stems].join('|')})["']`);
166
+ const out = [];
167
+ for (const { path: p } of intel.store.all('SELECT path FROM files WHERE is_test = 1 ORDER BY path')) {
168
+ let text;
169
+ try { text = fs.readFileSync(path.join(intel.root, p), 'utf8'); } catch { continue; }
170
+ if (re.test(text)) out.push(p);
171
+ }
172
+ return out;
173
+ }
174
+
175
+ export async function checkChanges(intel, { base = 'HEAD', files = null, tests = true, byName = false } = {}) {
145
176
  const root = intel.root;
177
+ if (!isRevision(base)) return { error: `Not a git revision: ${base}` };
146
178
  const changed = changedFiles(root, base);
147
179
  if (!changed) return { error: `git diff against ${base} failed — is this a git repository with that commit?` };
148
180
  // the edited files must be re-indexed now, whatever the file watcher has seen so far
149
181
  await intel.ensureFresh();
150
182
  await intel.ix.syncPaths([...changed.keys()]);
151
183
  const targets = [...changed.keys()].filter(f => specForPath(f) && (!files || files.some(x => f === x || f.startsWith(x.replace(/\/?$/, '/')))));
152
- const report = { base, files: targets.length, syntax: [], arity: [], removed: [], untouched: [], inherited: [], tests: [], commands: [], cases: [], casesTotal: 0, caseCommands: [], notes: [] };
184
+ const report = { base, files: targets.length, syntax: [], arity: [], removed: [], untouched: [], inherited: [], tests: [], commands: [], byName: [], byNameCommands: [], cases: [], casesTotal: 0, caseCommands: [], notes: [] };
153
185
  if (!targets.length) return report;
154
186
  // callers only need a syntax tree; definitions (changed files, callees) also need symbols + arities
155
187
  const treeCache = new Map(), symCache = new Map();
@@ -158,10 +190,10 @@ export async function checkChanges(intel, { base = 'HEAD', files = null, tests =
158
190
  let v = null;
159
191
  const spec = specForPath(rel);
160
192
  try {
161
- if (spec) {
162
- const src = fs.readFileSync(path.join(root, rel), 'utf8');
193
+ const src = spec ? readRepoFile(root, rel, MAX_FILE_BYTES) : null;
194
+ if (src !== null) {
163
195
  const { parser } = await prepareLanguage(spec);
164
- v = { tree: parser.parse(src), lines: src.split(/\r?\n/), src, spec };
196
+ v = { tree: parseSource(spec, parser, src), lines: src.split(/\r?\n/), src, spec };
165
197
  }
166
198
  } catch { v = null; }
167
199
  treeCache.set(rel, v);
@@ -276,7 +308,7 @@ export async function checkChanges(intel, { base = 'HEAD', files = null, tests =
276
308
  const files = new Map();
277
309
  const mentions = (p, word) => {
278
310
  const key = p + '\u0000' + word;
279
- if (!files.has(key)) { const lines = intel.fileLines(p); files.set(key, !!lines && lines.some(l => new RegExp(`\\b${word.replace(/[$]/g, '\\$')}\\b`).test(l))); }
311
+ if (!files.has(key)) { const lines = intel.fileLines(p); files.set(key, !!lines && lines.some(l => new RegExp(`\\b${escapeRegExp(word)}\\b`).test(l))); }
280
312
  return files.get(key);
281
313
  };
282
314
  rows = rows.filter(r => {
@@ -297,19 +329,19 @@ export async function checkChanges(intel, { base = 'HEAD', files = null, tests =
297
329
  const members = new Map();
298
330
  for (const id of seeds) { const m = memberOf(intel, id); if (m && !m.is_test) members.set(m.id, m); }
299
331
  for (const m of members.values()) {
300
- const { inherit, override } = intel.inheritance(m.id);
301
- if (!inherit.length && !override.length) continue;
332
+ const { inherit, extend, override } = intel.inheritance(m.id);
333
+ if (!inherit.length && !extend.length && !override.length) continue;
302
334
  const owner = m.parent_id != null ? intel.sym(m.parent_id) : null;
303
335
  const type = owner?.qname ?? m.qname.split('.').slice(0, -1).join('.');
304
- const e = byType.get(type) ?? byType.set(type, { type, path: m.path, members: [], inherit: new Map(), override: [] }).get(type);
336
+ const e = byType.get(type) ?? byType.set(type, { type, path: m.path, members: [], inherit: new Map(), extend: new Map(), override: [] }).get(type);
305
337
  e.members.push(m.name);
306
- for (const s of inherit) {
338
+ for (const [list, into] of [[inherit, e.inherit], [extend, e.extend]]) for (const s of list) {
307
339
  inheritorFiles.add(s.path); // a test subclass is not listed, but its file is a test to run
308
- if (!s.is_test) e.inherit.set(s.id, { qname: s.qname, path: s.path });
340
+ if (!s.is_test) into.set(s.id, { qname: s.qname, path: s.path });
309
341
  }
310
342
  for (const s of override) if (!s.is_test) e.override.push({ qname: `${s.qname}.${m.name}`, path: s.path });
311
343
  }
312
- report.inherited = [...byType.values()].map(e => ({ ...e, inherit: [...e.inherit.values()] })).filter(e => e.inherit.length || e.override.length);
344
+ report.inherited = [...byType.values()].map(e => ({ ...e, inherit: [...e.inherit.values()], extend: [...e.extend.values()] })).filter(e => e.inherit.length || e.extend.length || e.override.length);
313
345
 
314
346
  // ── tests to run ─────────────────────────────────────────────────────────────
315
347
  if (seeds.length) {
@@ -322,6 +354,12 @@ export async function checkChanges(intel, { base = 'HEAD', files = null, tests =
322
354
  for (const rel of targets) if (intel.store.get('SELECT is_test FROM files WHERE path = ?', rel)?.is_test) tests.add(rel);
323
355
  report.tests = [...tests].sort();
324
356
  report.commands = testCommands(root, report.tests.slice(0, 40));
357
+ // opt-in: the test files that name a changed module in a string (measured: more of the tests
358
+ // a fix breaks are found, fewer test files are avoided, and nothing gained outside sqlglot)
359
+ if (byName) {
360
+ report.byName = testsNamingModules(intel, [...targets, ...inheritorFiles]).filter(t => !tests.has(t));
361
+ report.byNameCommands = testCommands(root, report.byName.slice(0, 40));
362
+ }
325
363
  // the test functions closest to the change: changed themselves, calling it, or calling a
326
364
  // function that calls it — running these first verifies a fix in seconds
327
365
  const near = new Map(seeds.map(id => [id, 0]));
@@ -219,10 +219,11 @@ export function factsForRange(intel, file, from, to, { max = 6, shown = new Set(
219
219
  if (fam.up.length) parts.push(`overrides ${fam.up.slice(0, 2).map(u => `${short(u)} (${loc(u)})`).join(', ')}`);
220
220
  if (inh.override.length) parts.push(`overridden in ${inh.override.length} subclass${inh.override.length > 1 ? 'es' : ''}: ${inh.override.slice(0, 4).map(o => `${o.name} (${o.path})`).join(', ')}${inh.override.length > 4 ? ', …' : ''}`);
221
221
  if (inh.inherit.length) parts.push(`inherited unchanged by ${inh.inherit.length}: ${inh.inherit.slice(0, 5).map(o => o.name).join(', ')}${inh.inherit.length > 5 ? ', …' : ''}`);
222
- if (parts.length && (fam.up.length || inh.override.length || inh.inherit.length >= 1)) {
222
+ if (inh.extend.length) parts.push(`extended (their version uses this one) by ${inh.extend.length}: ${inh.extend.slice(0, 5).map(o => o.name).join(', ')}${inh.extend.length > 5 ? ', …' : ''}`);
223
+ if (parts.length && (fam.up.length || inh.override.length || inh.inherit.length >= 1 || inh.extend.length)) {
223
224
  const downIds = fam.down.map(d => d.id);
224
225
  push({ kind: 'overrides', tier: 'exact', key: `ovr:${s.id}`, targets: [...fam.up.map(u => u.id), ...downIds],
225
- text: `${s.qname}: ${parts.join('; ')}` }, inh.override.length || inh.inherit.length ? 4 : 2);
226
+ text: `${s.qname}: ${parts.join('; ')}` }, inh.override.length || inh.inherit.length || inh.extend.length ? 4 : 2);
226
227
  }
227
228
  }
228
229
  // decorators
@@ -0,0 +1,405 @@
1
+ /**
2
+ * Spring dependency injection: which bean an injection point receives. A field, constructor or
3
+ * `@Bean` parameter declares a type, usually an interface; Spring gives it the bean of that type
4
+ * (a class annotated as a component, or what a `@Bean` method returns), choosing among several by
5
+ * `@Qualifier`, `@Primary` and then the parameter's name. The compiler sees the interface, not the
6
+ * implementation that runs.
7
+ *
8
+ * Recognised from the indexed source (Java):
9
+ * - beans: classes annotated @Component, @Service, @Repository, @Controller, @RestController,
10
+ * @Configuration, @SpringBootApplication (and the advice variants), @ConfigurationProperties classes
11
+ * that something registers (@EnableConfigurationProperties naming them, or a
12
+ * @ConfigurationPropertiesScan), with the name Spring gives them (the annotation's
13
+ * value or the decapitalised class name), @Primary, @Qualifier and the conditions that may
14
+ * leave them out (@Profile, @Conditional…); `@Bean` methods, by return type, with their names;
15
+ * - beans the framework generates at run time, as such: Feign clients (@FeignClient, a primary
16
+ * proxy), Spring Data repositories (interfaces extending a `…Repository`) and MyBatis mappers
17
+ * (@Mapper, or interfaces in a package named by @MapperScan);
18
+ * - injection points: fields and setters with @Autowired, @Inject or @Resource; the parameters of
19
+ * a component's constructor (its only one, or the @Autowired one) and of `@Bean` methods; the
20
+ * final fields of a component that Lombok's @RequiredArgsConstructor injects. @Value parameters
21
+ * are configuration, not beans.
22
+ * A type a bean is assignable to is its class or anything it extends or implements, followed
23
+ * through the index; a type the index does not bind (a library type) is matched by its name.
24
+ * Collections (`List<T>`, `Map<String, T>`…) receive every bean of the element type. A build module
25
+ * (the nearest directory with a pom.xml or build.gradle) holding a @SpringBootApplication is one
26
+ * application, made of itself and the modules its build file depends on (Maven artifactIds and
27
+ * Gradle `project(':…')` of this repository): an injection point sees the beans of the
28
+ * applications its module is part of, never another application's; a shared module's point is
29
+ * filled by each application that includes it. Not modelled:
30
+ * custom stereotype annotations, beans declared in XML, auto-configuration from libraries and
31
+ * generics as a qualifier — what a library provides is reported as not in the indexed code.
32
+ * Test files are left out.
33
+ */
34
+
35
+ const STEREOTYPE = /^(Component|Service|Repository|Controller|RestController|Configuration|ControllerAdvice|RestControllerAdvice|SpringBootApplication|ConfigurationProperties)$/;
36
+ const POINT = /^(Autowired|Inject|Resource)$/;
37
+ const CONDITION = /^(Profile|Conditional\w*)$/;
38
+ const MODIFIER = /^(private|public|protected|static|final|transient|volatile|abstract|default|synchronized)$/;
39
+ const MANY = /^(List|Set|Collection|Iterable|Map|SortedSet|SortedMap|LinkedHashMap|HashMap)$/;
40
+ const WRAP = /^(Optional|ObjectProvider|ObjectFactory|Provider|Lazy)$/;
41
+ const ANNOTATION = /@[\w.]+(?:\s*\((?:[^()"]|"[^"]*"|\((?:[^()"]|"[^"]*")*\))*\))?/g;
42
+
43
+ import fs from 'node:fs';
44
+ import path from 'node:path';
45
+
46
+ const BUILD_FILES = ['pom.xml', 'build.gradle', 'build.gradle.kts'];
47
+
48
+ const decorators = (s) => { try { return JSON.parse(s.decorators || '[]'); } catch { return []; } };
49
+ const bases = (s) => { try { return JSON.parse(s.bases || '[]'); } catch { return []; } };
50
+ const strip = (s) => String(s ?? '').trim().replace(/\s+/g, ' ');
51
+
52
+ /** Module directory → the module directories its build file depends on (pom.xml artifactIds, Gradle project paths). */
53
+ function moduleDeps(root, mods) {
54
+ const info = new Map();
55
+ for (const m of mods) {
56
+ const dir = path.join(root, m);
57
+ const pom = read(path.join(dir, 'pom.xml'));
58
+ if (pom != null) {
59
+ let body = pom;
60
+ for (let prev; prev !== body;) { prev = body; body = body.replace(/<!--[\s\S]*?-->/g, ''); }
61
+ const own = /<artifactId>\s*([^<\s]+)\s*<\/artifactId>/.exec(body.replace(/<parent>[\s\S]*?<\/parent>/, ''))?.[1];
62
+ const ds = [...(body.match(/<dependencies>[\s\S]*?<\/dependencies>/g) ?? []).join('\n').matchAll(/<artifactId>\s*([^<\s]+)\s*<\/artifactId>/g)].map(x => x[1]);
63
+ info.set(m, { names: [own, path.posix.basename(m)].filter(Boolean), deps: ds });
64
+ continue;
65
+ }
66
+ const gradle = read(path.join(dir, 'build.gradle')) ?? read(path.join(dir, 'build.gradle.kts'));
67
+ if (gradle != null) info.set(m, { names: [path.posix.basename(m)], deps: [...gradle.matchAll(/project\(\s*(?:path\s*[:=]\s*)?['"]:?([\w:.-]+)['"]/g)].map(x => x[1].split(':').pop()) });
68
+ }
69
+ const byName = new Map();
70
+ for (const [m, i] of info) for (const n of i.names) if (!byName.has(n)) byName.set(n, m);
71
+ const out = new Map();
72
+ for (const [m, i] of info) out.set(m, new Set(i.deps.map(d => byName.get(d)).filter(d => d && d !== m)));
73
+ return out;
74
+ }
75
+
76
+ function read(f) { try { return fs.readFileSync(f, 'utf8'); } catch { return null; } }
77
+
78
+ /** Spring's default bean name: `FooService` → `fooService`, but `URLMapper` stays `URLMapper`. */
79
+ const beanName = (cls) => /^[A-Z]{2}/.test(cls) ? cls : cls[0].toLowerCase() + cls.slice(1);
80
+
81
+ /** The source of a declaration from its first line (annotations included) to its name, or to `upto`. */
82
+ function headText(lines, s, upto = null) {
83
+ const end = upto ?? s.name_line;
84
+ const out = [];
85
+ for (let l = s.start_line; l <= end && l <= lines.length; l++) out.push(l === s.name_line && upto == null ? lines[l - 1].slice(0, s.name_col) : lines[l - 1]);
86
+ return out.join('\n');
87
+ }
88
+
89
+ /** The annotations in a text: [{ name, args }] with `args` the parenthesised text, if any. */
90
+ function annotations(text) {
91
+ return [...text.matchAll(ANNOTATION)].map(m => {
92
+ const a = /^@([\w.]+)\s*(?:\(([\s\S]*)\))?$/.exec(m[0]);
93
+ return { name: a[1].split('.').pop(), args: a[2] ?? null, index: m.index, length: m[0].length };
94
+ });
95
+ }
96
+
97
+ /** The string values of an annotation's `value`/`name` (or bare) argument: `("x")`, `(name = "x")`, `({"a", "b"})`. */
98
+ function annotationNames(args, keys = ['value', 'name']) {
99
+ if (args == null) return [];
100
+ const t = args.trim();
101
+ const kv = new RegExp(`\\b(?:${keys.join('|')})\\s*=\\s*(\\{[^}]*\\}|"[^"]*")`).exec(t);
102
+ const v = kv ? kv[1] : (/^(\{[^}]*\}|"[^"]*")/.exec(t)?.[1] ?? '');
103
+ return [...v.matchAll(/"([^"]*)"/g)].map(m => m[1]).filter(Boolean);
104
+ }
105
+
106
+ /** A declared type → { raw, many, text }: `List<Foo>` → raw Foo, many; `Optional<Foo>` → Foo. */
107
+ function typeOf(text) {
108
+ let t = strip(text).replace(/\.\.\.$/, '');
109
+ let many = false;
110
+ for (let i = 0; i < 3; i++) {
111
+ const g = /^([\w.]+)\s*<(.*)>$/.exec(t);
112
+ if (!g) break;
113
+ const head = g[1].split('.').pop();
114
+ if (MANY.test(head)) { many = true; t = splitTop(g[2]).pop().trim(); continue; }
115
+ if (WRAP.test(head)) { t = g[2].trim(); continue; }
116
+ break;
117
+ }
118
+ if (t.endsWith('[]')) { many = true; t = t.slice(0, -2); }
119
+ const raw = /^([\w.]+)/.exec(t)?.[1]?.split('.').pop() ?? null;
120
+ return { raw, many, text: strip(text) };
121
+ }
122
+
123
+ /** Split at commas outside <>, () and {}. */
124
+ function splitTop(s) {
125
+ const out = [];
126
+ let depth = 0, cur = '', q = false;
127
+ for (const ch of s) {
128
+ if (ch === '"') q = !q;
129
+ if (!q && '<({'.includes(ch)) depth++;
130
+ if (!q && '>)}'.includes(ch)) depth--;
131
+ if (!q && ch === ',' && depth === 0) { out.push(cur); cur = ''; } else cur += ch;
132
+ }
133
+ if (cur.trim()) out.push(cur);
134
+ return out;
135
+ }
136
+
137
+ /** A parameter list's text (`(…)` after the name) → the parameters, with their line. */
138
+ function params(lines, s) {
139
+ let text = '', depth = 0, started = false;
140
+ const lineAt = [];
141
+ for (let l = s.name_line; l <= Math.min(lines.length, s.name_line + 40); l++) {
142
+ const src = l === s.name_line ? lines[l - 1].slice(s.name_col) : lines[l - 1];
143
+ for (const ch of src) {
144
+ if (ch === '(') { depth++; if (!started) { started = true; continue; } }
145
+ if (ch === ')') { depth--; if (started && depth === 0) return parse(); }
146
+ if (started) { text += ch; lineAt.push(l); }
147
+ }
148
+ if (started) { text += ' '; lineAt.push(l); }
149
+ }
150
+ return [];
151
+ function parse() {
152
+ const out = [];
153
+ let at = 0;
154
+ for (const part of splitTop(text)) {
155
+ const offset = text.indexOf(part, at);
156
+ at = offset + part.length;
157
+ const anns = annotations(part);
158
+ let rest = part;
159
+ for (const a of [...anns].reverse()) rest = rest.slice(0, a.index) + rest.slice(a.index + a.length);
160
+ const words = strip(rest).replace(/\bfinal\s+/, '').split(' ');
161
+ if (words.length < 2) continue;
162
+ const name = words.pop();
163
+ const lead = part.length - part.trimStart().length;
164
+ out.push({ name, type: words.join(' '), anns, line: lineAt[Math.min(lineAt.length - 1, offset + lead)] ?? s.name_line });
165
+ }
166
+ return out;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Beans and injection points of the repository's Java code. Each injection point carries the
172
+ * beans it may receive (`from`) and why (`why`); see injection.mjs for the shapes.
173
+ */
174
+ export function collectSpring(intel) {
175
+ const files = intel.store.all(`SELECT id, path, package FROM files WHERE lang = 'java' AND is_test = 0`);
176
+ if (!files.length) return { providers: [], injections: [] };
177
+ const fileOf = new Map(files.map(f => [f.id, f]));
178
+ const ids = files.map(f => f.id);
179
+ const syms = [];
180
+ for (let i = 0; i < ids.length; i += 500) {
181
+ const chunk = ids.slice(i, i + 500);
182
+ syms.push(...intel.store.all(`SELECT s.*, f.path FROM symbols s JOIN files f ON f.id = s.file_id WHERE s.file_id IN (${chunk.map(() => '?').join(',')})`, ...chunk));
183
+ }
184
+ const byId = new Map(syms.map(s => [s.id, s]));
185
+ const children = new Map();
186
+ for (const s of syms) if (s.parent_id != null) (children.get(s.parent_id) ?? children.set(s.parent_id, []).get(s.parent_id)).push(s);
187
+
188
+ // what extends or implements what (inherit references bound by the index), for assignability
189
+ const subs = new Map();
190
+ for (const r of intel.store.all(`SELECT r.src_id, r.dst_id FROM refs r JOIN files f ON f.id = r.file_id WHERE r.kind = 'inherit' AND f.lang = 'java' AND r.dst_id IS NOT NULL AND r.src_id IS NOT NULL`)) {
191
+ (subs.get(r.dst_id) ?? subs.set(r.dst_id, new Set()).get(r.dst_id)).add(r.src_id);
192
+ }
193
+ const supertypesCache = new Map();
194
+ const supersOf = new Map();
195
+ for (const [base, ss] of subs) for (const x of ss) (supersOf.get(x) ?? supersOf.set(x, []).get(x)).push(base);
196
+ /** A type's id and the ids of everything it extends or implements (through the index). */
197
+ const supers = (id) => {
198
+ if (supertypesCache.has(id)) return supertypesCache.get(id);
199
+ const out = new Set([id]);
200
+ const stack = [id];
201
+ while (stack.length) for (const b of supersOf.get(stack.pop()) ?? []) if (!out.has(b)) { out.add(b); stack.push(b); }
202
+ supertypesCache.set(id, out);
203
+ return out;
204
+ };
205
+ /** The names of a type's supertypes the index does not bind (library types), for name matching. */
206
+ const superNames = (id) => {
207
+ const out = new Set();
208
+ for (const x of supers(id)) { const s = byId.get(x); if (!s) continue; out.add(s.name); for (const b of bases(s)) out.add(/^([\w.]+)/.exec(b)?.[1]?.split('.').pop()); }
209
+ return out;
210
+ };
211
+
212
+ /** The definition a type name on lines [l0, l1] of a file is bound to. */
213
+ const bindType = (fileId, l0, l1, name) => {
214
+ if (!name) return null;
215
+ const r = intel.store.get(`SELECT dst_id FROM refs WHERE file_id = ? AND name = ? AND line BETWEEN ? AND ? AND kind IN ('type', 'inherit') AND dst_id IS NOT NULL LIMIT 1`, fileId, name, l0, l1);
216
+ return r ? (byId.get(r.dst_id) ?? intel.sym(r.dst_id)) : null;
217
+ };
218
+
219
+ // @MapperScan packages: the interfaces there are MyBatis mappers
220
+ const mapperPackages = [];
221
+ for (const s of syms) if (decorators(s).includes('MapperScan')) {
222
+ const lines = intel.fileLines(s.path) ?? [];
223
+ for (const a of annotations(headText(lines, s))) if (a.name === 'MapperScan') mapperPackages.push(...annotationNames(a.args, ['value', 'basePackages']));
224
+ }
225
+
226
+ // build modules: an application's beans are not another application's
227
+ const moduleCache = new Map();
228
+ const moduleOf = (rel) => {
229
+ let dir = path.posix.dirname(rel);
230
+ const seen = [];
231
+ while (true) {
232
+ if (moduleCache.has(dir)) { const m = moduleCache.get(dir); for (const d of seen) moduleCache.set(d, m); return m; }
233
+ seen.push(dir);
234
+ if (BUILD_FILES.some(b => fs.existsSync(path.join(intel.root, dir, b))) || dir === '.' || dir === '') {
235
+ const m = dir === '' ? '.' : dir;
236
+ for (const d of seen) moduleCache.set(d, m);
237
+ return m;
238
+ }
239
+ dir = path.posix.dirname(dir);
240
+ }
241
+ };
242
+ const apps = new Set(syms.filter(s => s.kind === 'class' && decorators(s).includes('SpringBootApplication')).map(s => moduleOf(s.path)));
243
+ // which modules an application includes: its build file's dependencies on other modules here
244
+ const mods = new Set(syms.map(s => moduleOf(s.path)));
245
+ const deps = moduleDeps(intel.root, mods);
246
+ const closure = (m) => { const out = new Set([m]), st = [m]; while (st.length) for (const d of deps.get(st.pop()) ?? []) if (!out.has(d)) { out.add(d); st.push(d); } return out; };
247
+ const appMods = new Map([...apps].map(a => [a, closure(a)]));
248
+ const known = [...deps.values()].some(d => d.size);
249
+ // the applications a module is part of: itself if it is one, else those that include it
250
+ // (without dependency information, every application, as a shared library)
251
+ const appsOf = (m) => apps.has(m) ? [m] : [...apps].filter(a => !known || appMods.get(a).has(m));
252
+ const visible = (pointFile, p) => {
253
+ const a = moduleOf(pointFile), b = moduleOf(p.file);
254
+ if (a === b || !apps.size) return true;
255
+ const pa = appsOf(a);
256
+ // a module no application includes: its own beans and those of other such modules
257
+ if (!pa.length) return !appsOf(b).length || !known;
258
+ return pa.some(x => known ? appMods.get(x).has(b) : (b === x || !apps.has(b)));
259
+ };
260
+
261
+ // @ConfigurationProperties classes are beans only when registered
262
+ const enabledProps = new Set();
263
+ let propsScan = false;
264
+ for (const s of syms) {
265
+ const dec = decorators(s);
266
+ if (dec.includes('ConfigurationPropertiesScan')) propsScan = true;
267
+ if (!dec.includes('EnableConfigurationProperties')) continue;
268
+ for (const a of annotations(headText(intel.fileLines(s.path) ?? [], s))) if (a.name === 'EnableConfigurationProperties') for (const m of (a.args ?? '').matchAll(/([\w.]+)\.class/g)) enabledProps.add(m[1].split('.').pop());
269
+ }
270
+ const isBeanClass = (s, dec) => dec.some(d => STEREOTYPE.test(d) && d !== 'ConfigurationProperties') || (dec.includes('ConfigurationProperties') && (propsScan || enabledProps.has(s.name)));
271
+
272
+ const providers = [];
273
+ const conditionsOf = (anns) => anns.filter(a => CONDITION.test(a.name)).map(a => `@${a.name}${a.args != null ? `(${strip(a.args)})` : ''}`);
274
+ for (const s of syms) {
275
+ const dec = decorators(s);
276
+ const lines = intel.fileLines(s.path) ?? [];
277
+ if (s.kind === 'class' && isBeanClass(s, dec) && !/\babstract\b/.test(s.sig ?? '')) {
278
+ const anns = annotations(headText(lines, s));
279
+ const st = anns.find(a => STEREOTYPE.test(a.name) && (a.name !== 'ConfigurationProperties' || !dec.some(d => STEREOTYPE.test(d) && d !== 'ConfigurationProperties')));
280
+ // @ConfigurationProperties("prefix") names a prefix, not the bean
281
+ const name = (st?.name !== 'ConfigurationProperties' && annotationNames(st?.args)[0]) || beanName(s.name);
282
+ providers.push({ how: `@${st?.name ?? dec.find(d => STEREOTYPE.test(d))}`, target: s, targetText: s.qname, name, primary: dec.includes('Primary'),
283
+ qualifiers: anns.filter(a => a.name === 'Qualifier' || a.name === 'Named').flatMap(a => annotationNames(a.args)),
284
+ conditions: conditionsOf(anns), types: supers(s.id), typeNames: superNames(s.id), file: s.path, line: s.start_line, exact: true });
285
+ } else if (s.kind === 'method' && dec.includes('Bean')) {
286
+ const anns = annotations(headText(lines, s));
287
+ const ret = typeOf(s.type ?? '');
288
+ const rt = bindType(s.file_id, s.start_line, s.name_line, ret.raw);
289
+ const names = annotationNames(anns.find(a => a.name === 'Bean')?.args);
290
+ providers.push({ how: '@Bean', target: s, targetText: `${s.qname}()`, name: names[0] ?? s.name, aliases: names.slice(1), primary: dec.includes('Primary'),
291
+ qualifiers: anns.filter(a => a.name === 'Qualifier' || a.name === 'Named').flatMap(a => annotationNames(a.args)),
292
+ conditions: conditionsOf(anns), types: rt ? supers(rt.id) : new Set(), typeNames: new Set([ret.raw, ...(rt ? superNames(rt.id) : [])]), returns: ret.raw, file: s.path, line: s.start_line, exact: !!rt });
293
+ } else if (s.kind === 'interface') {
294
+ // beans the framework generates: Feign proxies, Spring Data repositories, MyBatis mappers
295
+ const pkg = fileOf.get(s.file_id)?.package ?? '';
296
+ const repo = [...superNames(s.id)].some(n => n && /^(Repository|CrudRepository|ListCrudRepository|PagingAndSortingRepository|JpaRepository|MongoRepository|ElasticsearchRepository|ReactiveCrudRepository|R2dbcRepository|Neo4jRepository|CassandraRepository|QuerydslPredicateExecutor|JpaSpecificationExecutor|KeyValueRepository|RedisRepository)$/.test(n));
297
+ const how = dec.includes('FeignClient') ? 'Feign client proxy'
298
+ : repo ? 'Spring Data repository'
299
+ : dec.includes('Mapper') || mapperPackages.some(p => pkg === p || pkg.startsWith(p + '.')) ? 'MyBatis mapper'
300
+ : null;
301
+ if (how) providers.push({ how, target: s, targetText: `${s.qname} (generated at run time)`, name: beanName(s.name), primary: how === 'Feign client proxy', qualifiers: [], conditions: [],
302
+ types: supers(s.id), typeNames: superNames(s.id), generated: true, file: s.path, line: s.start_line, exact: true });
303
+ }
304
+ }
305
+ for (const p of providers) p.key = `id:${p.target.id}`;
306
+
307
+ /** The beans an injection point may receive, and why that one. */
308
+ const resolve = ({ type, typeSym, qualifier, name, byName, file }) => {
309
+ let cands = providers.filter(p => (typeSym ? p.types.has(typeSym.id) : p.typeNames.has(type.raw)) && visible(file, p));
310
+ // a shared module's injection point: each application that includes it brings its own bean
311
+ const perApp = !apps.has(moduleOf(file)) && new Set(cands.flatMap(p => appsOf(moduleOf(p.file)).length === 1 && apps.has(moduleOf(p.file)) ? [moduleOf(p.file)] : [])).size > 1;
312
+ let why = typeSym ? 'by type' : `by type name ${type.raw} (a type the index does not bind)`;
313
+ if (byName) {
314
+ const n = providers.filter(p => p.name === name && (cands.includes(p)));
315
+ if (n.length === 1) return { from: n, why: `by name "${name}" (@Resource)` };
316
+ }
317
+ if (qualifier) {
318
+ cands = cands.filter(p => p.name === qualifier || p.aliases?.includes(qualifier) || p.qualifiers.includes(qualifier));
319
+ return { from: cands, why: `@Qualifier("${qualifier}")` };
320
+ }
321
+ if (type.many) return { from: cands, why: `every bean of type ${type.raw}` };
322
+ if (perApp) return { from: cands, why: 'the bean of the application that includes this module (one per application)' };
323
+ if (cands.length > 1) {
324
+ const prim = cands.filter(p => p.primary);
325
+ if (prim.length === 1) return { from: prim, why: `@Primary, over ${cands.filter(p => p !== prim[0]).map(p => p.target.name).join(', ')}`, others: cands.filter(p => p !== prim[0]) };
326
+ const byParam = cands.filter(p => p.name === name || p.aliases?.includes(name));
327
+ if (byParam.length === 1) return { from: byParam, why: `by the name "${name}"`, others: cands.filter(p => p !== byParam[0]) };
328
+ const why2 = cands.every(p => p.conditions.length) ? 'one of these, by the active profile or conditions' : 'ambiguous: several beans match and none is @Primary or named so';
329
+ return { from: cands, why: why2 };
330
+ }
331
+ return { from: cands, why };
332
+ };
333
+
334
+ const injections = [];
335
+ const point = (owner, s, decl, line, anns, extra = {}) => {
336
+ if (anns.some(a => a.name === 'Value')) return;
337
+ const type = typeOf(decl.type);
338
+ if (!type.raw || /^(String|int|long|boolean|double|float|short|byte|char|Integer|Long|Boolean|Double|Object)$/.test(type.raw)) return;
339
+ const typeSym = bindType(s.file_id, Math.min(line, s.start_line), Math.max(line, s.name_line) + 1, type.raw);
340
+ const qualifier = anns.filter(a => a.name === 'Qualifier' || a.name === 'Named').flatMap(a => annotationNames(a.args))[0] ?? null;
341
+ const res = anns.find(a => a.name === 'Resource');
342
+ const name = (res && annotationNames(res.args, ['name'])[0]) || decl.name;
343
+ const r = resolve({ type, typeSym, qualifier, name, byName: !!res, file: s.path });
344
+ injections.push({ key: typeSym ? `id:${typeSym.id}` : `text:${type.raw}`, token: typeSym?.qname ?? type.raw, exact: !!typeSym, owner, param: decl.name, type: type.text,
345
+ file: s.path, line, framework: 'spring', ...r, ...extra });
346
+ };
347
+ // Spring injects only into beans: a class that is one, that a @Bean method returns, or that a bean extends
348
+ const beanIds = new Set(providers.filter(p => !p.generated && p.how !== '@Bean').map(p => p.target.id));
349
+ const returned = new Set(providers.filter(p => p.how === '@Bean').map(p => p.returns));
350
+ // a @Bean declared as an interface but returning `new Impl(…)`: Impl is the bean
351
+ for (const p of providers) if (p.how === '@Bean') {
352
+ const body = (intel.fileLines(p.target.path) ?? []).slice(p.target.start_line - 1, p.target.end_line).join('\n');
353
+ for (const m of body.matchAll(/\bnew\s+([A-Z][\w]*)\s*[(<]/g)) returned.add(m[1]);
354
+ }
355
+ const isBeanOwner = (c) => {
356
+ if (!c) return false;
357
+ if (beanIds.has(c.id) || returned.has(c.name)) return true;
358
+ const stack = [c.id], seen = new Set(stack);
359
+ while (stack.length) for (const x of subs.get(stack.pop()) ?? []) {
360
+ const k = byId.get(x);
361
+ if (beanIds.has(x) || (k && returned.has(k.name))) return true;
362
+ if (!seen.has(x)) { seen.add(x); stack.push(x); }
363
+ }
364
+ return false;
365
+ };
366
+ for (const s of syms) {
367
+ const dec = decorators(s);
368
+ const lines = intel.fileLines(s.path) ?? [];
369
+ const owner = s.parent_id != null ? byId.get(s.parent_id) ?? null : null;
370
+ if (!isBeanOwner(owner)) continue;
371
+ if (s.kind === 'field' && dec.some(d => POINT.test(d)) && !s.is_static) {
372
+ const head = headText(lines, s);
373
+ const anns = annotations(head);
374
+ let rest = head;
375
+ for (const a of [...anns].reverse()) rest = rest.slice(0, a.index) + rest.slice(a.index + a.length);
376
+ const words = strip(rest).split(' ').filter(w => w && !MODIFIER.test(w));
377
+ point(owner, s, { name: s.name, type: words.join(' ') || s.type || '' }, s.name_line, anns);
378
+ } else if ((s.kind === 'method' || s.kind === 'constructor') && (dec.some(d => POINT.test(d)) || dec.includes('Bean'))) {
379
+ for (const p of params(lines, s)) point(owner, s, p, p.line, p.anns, dec.includes('Bean') ? { via: `@Bean ${s.name}()` } : {});
380
+ }
381
+ }
382
+ // constructor injection: a component's only constructor, or its @Autowired one (handled above)
383
+ for (const p of providers) {
384
+ // constructor binding of @ConfigurationProperties takes properties, not beans
385
+ if (p.how === '@Bean' || p.how === '@ConfigurationProperties' || p.generated) continue;
386
+ const kids = children.get(p.target.id) ?? [];
387
+ const ctors = kids.filter(k => k.kind === 'constructor');
388
+ const lines = intel.fileLines(p.target.path) ?? [];
389
+ if (ctors.length === 1 && !decorators(ctors[0]).some(d => POINT.test(d))) {
390
+ for (const x of params(lines, ctors[0])) point(p.target, ctors[0], x, x.line, x.anns);
391
+ } else if (!ctors.length && decorators(p.target).some(d => d === 'RequiredArgsConstructor' || d === 'AllArgsConstructor')) {
392
+ const all = decorators(p.target).includes('AllArgsConstructor');
393
+ for (const f of kids.filter(k => k.kind === 'field' && !k.is_static && !decorators(k).some(d => POINT.test(d)))) {
394
+ const head = headText(lines, f);
395
+ if (!all && !/\bfinal\b/.test(head)) continue;
396
+ const anns = annotations(head);
397
+ let rest = head;
398
+ for (const a of [...anns].reverse()) rest = rest.slice(0, a.index) + rest.slice(a.index + a.length);
399
+ const words = strip(rest).split(' ').filter(w => w && !MODIFIER.test(w));
400
+ point(p.target, f, { name: f.name, type: words.join(' ') || f.type || '' }, f.name_line, anns, { via: '@RequiredArgsConstructor' });
401
+ }
402
+ }
403
+ }
404
+ return { providers, injections };
405
+ }