graph-indexer 3.0.1 → 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 (52) hide show
  1. package/CHANGELOG.md +404 -0
  2. package/README.md +61 -8
  3. package/bin/graph-indexer.mjs +46 -5
  4. package/integrations/claude-code/.claude-plugin/plugin.json +1 -1
  5. package/integrations/claude-code/agents/code-structure.md +0 -3
  6. package/integrations/claude-code/skills/setup/SKILL.md +26 -0
  7. package/integrations/claude-code-local/.claude-plugin/plugin.json +9 -0
  8. package/integrations/{claude-code → claude-code-local}/.mcp.json +1 -1
  9. package/integrations/claude-code-local/README.md +10 -0
  10. package/integrations/claude-code-local/agents/code-structure.md +22 -0
  11. package/integrations/{claude-code → claude-code-local}/hooks/gi-hook.mjs +3 -1
  12. package/package.json +1 -1
  13. package/src/cli/helper.mjs +15 -1
  14. package/src/cli/hook.mjs +123 -41
  15. package/src/cli/init.mjs +16 -1
  16. package/src/cli/resident.mjs +1 -1
  17. package/src/index/indexer.mjs +77 -41
  18. package/src/index/modules.mjs +76 -2
  19. package/src/index/parse-worker.mjs +12 -0
  20. package/src/index/parsepool.mjs +73 -0
  21. package/src/index/resolver.mjs +362 -23
  22. package/src/mcp/server.mjs +16 -12
  23. package/src/mcp/tools.mjs +417 -25
  24. package/src/parse/arity.mjs +39 -2
  25. package/src/parse/extract.mjs +269 -45
  26. package/src/parse/lang/ecma.mjs +2 -0
  27. package/src/parse/lang/go.mjs +2 -0
  28. package/src/parse/lang/jvm.mjs +33 -0
  29. package/src/parse/lang/markdown.mjs +92 -0
  30. package/src/parse/lang/python.mjs +43 -0
  31. package/src/parse/lang/rust.mjs +215 -15
  32. package/src/parse/lang/sfc.mjs +125 -0
  33. package/src/parse/languages.mjs +9 -2
  34. package/src/query/check.mjs +49 -13
  35. package/src/query/facts.mjs +3 -2
  36. package/src/query/injection-spring.mjs +405 -0
  37. package/src/query/injection.mjs +173 -0
  38. package/src/query/intel.mjs +76 -14
  39. package/src/query/issue.mjs +156 -0
  40. package/src/query/orm.mjs +286 -0
  41. package/src/query/read.mjs +6 -1
  42. package/src/query/report.mjs +53 -0
  43. package/src/query/routes.mjs +142 -0
  44. package/src/query/showcase.mjs +59 -0
  45. package/src/search/search.mjs +85 -17
  46. package/src/search/semantic.mjs +201 -0
  47. package/src/search/vectors.mjs +122 -0
  48. package/src/store/db.mjs +22 -4
  49. package/vendor/grammars/README.md +47 -0
  50. package/vendor/grammars/kotlin.wasm.gz +0 -0
  51. package/vendor/grammars/manifest.json +4 -4
  52. /package/integrations/{claude-code → claude-code-local}/hooks/hooks.json +0 -0
package/src/mcp/tools.mjs CHANGED
@@ -1,27 +1,52 @@
1
1
  /**
2
- * The MCP tool surface: eight read-only tools, each answering one kind of question an agent has
2
+ * The MCP tool surface: nine read-only tools, each answering one kind of question an agent has
3
3
  * while changing code; descriptions are short because clients re-read them on every turn (`get_symbol`, which `read_code` replaced, stays callable for older clients).
4
4
  * Descriptions say when to use the tool (and when grep/read is better);
5
5
  * outputs are compact text with file:line locations and explicit totals/truncation notes.
6
6
  */
7
7
  import path from 'node:path';
8
8
  import { analyzeQuery } from '../search/tokenize.mjs';
9
+ import { MAX_QUERIES, WANTS_DOCS } from '../search/search.mjs';
9
10
  import { loc, clip, codeBlock, symHeader, confWord, matchingLines, plural } from './render.mjs';
10
11
  import { textSearch, findFiles, identifierOf } from '../query/textsearch.mjs';
11
12
  import { checkChanges } from '../query/check.mjs';
12
13
  import { definitionsUsed, testsUsing } from '../query/read.mjs';
13
14
  import { factsForRange, renderFacts } from '../query/facts.mjs';
15
+ import { testCommands } from '../query/testcmd.mjs';
16
+ import { showcaseText } from '../query/showcase.mjs';
17
+ import { collectRoutes, routesInQuery, matchRoutes } from '../query/routes.mjs';
18
+ import { collectInjection, providedBy, tokenUses, injectedInto } from '../query/injection.mjs';
19
+ import { collectModels, modelOf, prismaModelsNamed } from '../query/orm.mjs';
20
+ import { isIssueText, issueSeeds } from '../query/issue.mjs';
14
21
  import fs from 'node:fs';
15
22
  import { withinRoot } from '../util/paths.mjs';
16
23
 
17
24
  const TYPE_KINDS = new Set(['class', 'interface', 'struct', 'enum', 'trait', 'type', 'object', 'module', 'impl']);
18
25
  const VALUE_KINDS = new Set(['field', 'property', 'variable', 'constant']);
26
+ const LARGE_TABLE = 100;
27
+ /** Does a definition hold so many members (a token enum, a table of keywords) that one entry says little? */
28
+ const largeTable = (intel, id) => (intel.store.get('SELECT count(*) AS n FROM symbols WHERE parent_id = ?', id)?.n ?? 0) >= LARGE_TABLE;
19
29
  const CALLABLE_KINDS = new Set(['function', 'method']);
20
30
  // examples, docs snippets and sample apps come after the library code they exercise
21
31
  const EXAMPLE_PATH = /(^|\/)(docs?_src|docs?|examples?|samples?|demos?|tutorials?|benchmarks?|fixtures?|playground)\//i;
22
32
  const pathRank = (p) => (EXAMPLE_PATH.test(p) ? 1 : 0);
23
33
 
24
34
  export const TOOLS = [
35
+ {
36
+ name: 'explore',
37
+ title: 'Explore',
38
+ description: 'Start a task in one call: for a question, bug or feature in words ("why are expired tokens accepted"), an issue pasted as written, and/or named symbols, the most relevant definitions with their code, who calls them, overrides and subclasses, where the names they use are defined, and the tests to run with the command. Fits `max_tokens` and says what it left out.',
39
+ inputSchema: {
40
+ type: 'object',
41
+ properties: {
42
+ query: { type: 'string', description: 'The task, question or bug in words, or the issue as written (title first); identifiers in it are looked up exactly.' },
43
+ queries: { type: 'array', items: { type: 'string' }, maxItems: 6, description: 'Other phrasings or likely names for the same need, searched with the query and fused.' },
44
+ symbols: { type: 'array', items: { type: 'string' }, description: 'Symbols to centre on (name, Class.method, path:Name).' },
45
+ path: { type: 'string', description: 'Only search under this path prefix, e.g. "src/auth/".' },
46
+ max_tokens: { type: 'integer', minimum: 500, maximum: 6000, default: 2000, description: 'Answer budget (default 2000).' },
47
+ },
48
+ },
49
+ },
25
50
  {
26
51
  name: 'search_code',
27
52
  title: 'Search code',
@@ -30,11 +55,11 @@ export const TOOLS = [
30
55
  type: 'object',
31
56
  properties: {
32
57
  query: { type: 'string', description: 'What you are looking for: a behaviour description or identifier(s).' },
58
+ queries: { type: 'array', items: { type: 'string' }, maxItems: 6, description: 'Several phrasings or guessed names for one need, in one call; rankings are fused.' },
33
59
  path: { type: 'string', description: 'Only results under this path prefix, e.g. "src/auth/".' },
34
60
  kind: { type: 'string', description: 'Only this symbol kind (function, method, class, interface, struct, enum, type, field…).' },
35
61
  limit: { type: 'integer', minimum: 1, maximum: 30, default: 8, description: 'Max results (default 8).' },
36
62
  },
37
- required: ['query'],
38
63
  },
39
64
  },
40
65
  {
@@ -122,6 +147,7 @@ export const TOOLS = [
122
147
  properties: {
123
148
  files: { type: 'array', items: { type: 'string' }, description: 'Only check these changed files/directories (default: all changes).' },
124
149
  base: { type: 'string', default: 'HEAD', description: 'Git revision to compare with.' },
150
+ by_name: { type: 'boolean', default: false, description: 'Also list the test files that name a changed module in a string (a dialect, plugin or backend picked by name): more of the affected tests, many more files to run.' },
125
151
  },
126
152
  },
127
153
  },
@@ -161,13 +187,14 @@ export const LEGACY_TOOLS = [
161
187
  // loaded up front even when the client defers MCP tools behind a tool search: the one tool agents
162
188
  // called in real sessions (docs/AGENTIC-BENCHMARK.md, rt1–rt2); every loaded definition is re-read
163
189
  // on every turn, so the others wait behind the client's tool search.
164
- const ALWAYS_LOAD = new Set(['find_references']);
190
+ // `explore` joins it as the one call that opens a task (docs/PLAN-REFERENCIA.md §1.1; unmeasured).
191
+ const ALWAYS_LOAD = new Set(['find_references', 'explore']);
165
192
  for (const t of [...TOOLS, ...LEGACY_TOOLS]) {
166
193
  t.annotations = { title: t.title, readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
167
194
  if (ALWAYS_LOAD.has(t.name)) t._meta = { 'anthropic/alwaysLoad': true };
168
195
  }
169
196
 
170
- export const SERVER_INSTRUCTIONS = `A live structural index of this repository (definitions, references bound through scopes, imports and receiver types, call graph, tests), re-synced before every answer. Keep reading and searching text with your own tools; use this for what text search cannot answer exactly: the uses of a symbol that other code shares a name with (find_references), callers of callers (call_graph), what a signature change reaches (change_impact) and what your edit broke (check_changes). Answers state what the index cannot see.`;
197
+ export const SERVER_INSTRUCTIONS = `A live structural index of this repository (definitions, references bound through scopes, imports and receiver types, call graph, tests), re-synced before every answer. To start on a task in unfamiliar code, explore gives the relevant code, its callers, the definitions it uses and its tests in one call. Keep reading and searching text with your own tools; use this for what text search cannot answer exactly: the uses of a symbol that other code shares a name with (find_references), callers of callers (call_graph), what a signature change reaches (change_impact) and what your edit broke (check_changes). Answers state what the index cannot see.`;
171
198
 
172
199
  // ── handlers ─────────────────────────────────────────────────────────────────────
173
200
 
@@ -181,9 +208,25 @@ function describe(r, max = 140) {
181
208
 
182
209
  /** Matches for a target, with overloads of one method collapsed (they are one definition to an agent). */
183
210
  function pickSymbol(intel, target) {
184
- const { matches } = intel.findSymbols(target);
211
+ const { matches, sameName } = intel.findSymbols(target);
185
212
  const seen = new Set();
186
- return matches.filter(m => { const k = `${m.file_id}|${m.qname}|${m.kind}|${m.is_static}`; return !seen.has(k) && seen.add(k); });
213
+ const out = matches.filter(m => { const k = `${m.file_id}|${m.qname}|${m.kind}|${m.is_static}`; return !seen.has(k) && seen.add(k); });
214
+ if (sameName) out.sameName = sameName; // `Owner.name` where no `Owner` declares `name` (see notFound)
215
+ return out;
216
+ }
217
+
218
+ /** Files of the symbol's language that mention its name but have regions the parser could not read. */
219
+ function parseGaps(intel, s) {
220
+ return intel.filesWithParseErrors(s.name, { lang: s.lang, max: 20 });
221
+ }
222
+
223
+ /** Where uses of `s` may be missing because the parser could not read part of a file. */
224
+ function parseGapNote(gaps, s, max = 5) {
225
+ if (!gaps.length) return [];
226
+ const one = gaps.length === 1;
227
+ return [`Not fully parsed: ${gaps.length}${gaps.length >= 20 ? '+' : ''} file${one ? '' : 's'} that mention${one ? 's' : ''} "${s.name}" ${one ? 'has' : 'have'} code the parser could not read, where uses can be missing or bound to another "${s.name}" — read ${one ? 'it' : 'them'}:`,
228
+ ...gaps.slice(0, max).map(g => ` ${g.path}:${g.line}`),
229
+ ...(gaps.length > max ? [` … ${gaps.length - max} more`] : [])];
187
230
  }
188
231
 
189
232
  /** "other definitions" note: each alternative as a ready-to-use qualified target with its use count. */
@@ -198,7 +241,18 @@ function otherDefinitions(intel, matches, max = 5) {
198
241
  /** Which of several same-name definitions is shown: `Logger.error (static) in path:209`. */
199
242
  const shownAs = (s, matches) => `${s.qname}${s.is_static ? ' (static)' : ''} in ${matches.filter(m => m.path === s.path && m.qname === s.qname).length > 1 ? `${s.path}:${s.start_line}` : s.path}`;
200
243
 
201
- function notFound(intel, target) {
244
+ function notFound(intel, target, matches = null) {
245
+ if (matches?.sameName?.length) {
246
+ const parts = String(target).trim().replace(/\(\)$/, '').replace(/::|#|->/g, '.').split('.');
247
+ const name = parts.pop(), owner = parts[parts.length - 1];
248
+ const out = [`No definition of "${name}" in "${owner}": nothing indexed as "${owner}" declares it, so the uses of another "${name}" are not shown in its place.`];
249
+ out.push(`Definitions named "${name}" (pass one as symbol):`);
250
+ for (const m of matches.sameName.slice(0, 8)) out.push(` ${m.path}:${m.qname} ${m.kind} line ${m.start_line} (${plural(refSummary(intel, m.id).n, 'ref')})`);
251
+ if (matches.sameName.length > 8) out.push(` … ${matches.sameName.length - 8} more`);
252
+ const broken = intel.filesWithParseErrors(owner);
253
+ if (broken.length) out.push(`"${owner}" appears in ${plural(broken.length, 'file')} the parser could not fully read, so its declaration may be missing from the index: ${broken.slice(0, 5).map(b => `${b.path}:${b.line}`).join(', ')}${broken.length > 5 ? ', …' : ''}. Read ${broken.length === 1 ? 'it' : 'them'} directly.`);
254
+ return out.join('\n');
255
+ }
202
256
  const { results } = intel.search.search(String(target), { limit: 5 });
203
257
  const sugg = results.map(r => intel.sym(r.id)).filter(Boolean).map(s => ` ${symHeader(s)}`);
204
258
  return `No symbol named "${target}".` + (sugg.length ? `\nClosest matches:\n${sugg.join('\n')}` : `\nTry ${tn('search_code')} with a description of what it does.`);
@@ -240,7 +294,7 @@ function refSummary(intel, id) {
240
294
  * Hints inside tool output name the next tool to call; through the CLI they must name the
241
295
  * equivalent command instead (`symbol <target>`, not `get_symbol(…)`).
242
296
  */
243
- const CLI_NAMES = { search_code: 'search', search_text: 'grep', get_symbol: 'symbol', read_code: 'read', find_references: 'refs', call_graph: 'callgraph', change_impact: 'impact', check_changes: 'check', outline: 'outline', find_files: 'files' };
297
+ const CLI_NAMES = { explore: 'explore', search_code: 'search', search_text: 'grep', get_symbol: 'symbol', read_code: 'read', find_references: 'refs', call_graph: 'callgraph', change_impact: 'impact', check_changes: 'check', outline: 'outline', find_files: 'files' };
244
298
  let surface = 'mcp';
245
299
  const tn = (name) => (surface === 'cli' ? `\`${CLI_NAMES[name]}\`` : name);
246
300
 
@@ -271,6 +325,7 @@ export async function callTool(intel, name, args = {}, { cli = false } = {}) {
271
325
 
272
326
  async function dispatchTool(intel, name, args) {
273
327
  switch (name) {
328
+ case 'explore': return toolExplore(intel, args);
274
329
  case 'search_code': return toolSearch(intel, args);
275
330
  case 'search_text': return toolText(intel, args);
276
331
  case 'read_code': return toolRead(intel, args);
@@ -281,6 +336,10 @@ async function dispatchTool(intel, name, args) {
281
336
  case 'outline': return toolOutline(intel, args);
282
337
  case 'check_changes': return toolCheck(intel, args);
283
338
  case 'find_files': return toolFiles(intel, args); // CLI helper (`files`): MCP hosts have their own file finder
339
+ case 'demo': return showcaseText(intel);
340
+ case 'inject': return toolInject(intel, args); // CLI helper (`inject`): dependency-injection tokens, their providers and where they are injected
341
+ case 'models': return toolModels(intel, args); // CLI helper (`models`): ORM models, their tables, migrations and Prisma client calls
342
+ case 'routes': return toolRoutes(intel, args); // CLI helper (`routes`); explore finds the handler of a route named in its query // CLI helper (`demo`, end of `init`): grep versus exact uses, on this repository
284
343
  default: throw Object.assign(new Error(`Unknown tool: ${name}`), { code: -32602 });
285
344
  }
286
345
  }
@@ -300,15 +359,21 @@ function toolFiles(intel, { pattern, path: p = null, limit = 100 }) {
300
359
  return out.join('\n');
301
360
  }
302
361
 
303
- async function toolSearch(intel, { query, path: p = null, kind = null, limit = 8 }) {
304
- if (!query || !String(query).trim()) return 'Provide a query: a behaviour description or an identifier.';
305
- const { results, total } = intel.search.search(String(query), { limit: Math.min(Math.max(1, limit), 30), path: p || null, kinds: kind ? [kind] : null });
306
- if (!results.length) return `No matches for "${query}"${p ? ` under ${p}` : ''}. Try other words for the behaviour, an identifier fragment, or outline for a map of the repository.`;
362
+ function queryList(query, queries) {
363
+ const extra = Array.isArray(queries) ? queries : queries ? [queries] : [];
364
+ return [...new Set([query, ...extra].map(q => String(q ?? '').trim()).filter(Boolean))].slice(0, MAX_QUERIES);
365
+ }
366
+
367
+ async function toolSearch(intel, { query, queries = null, path: p = null, kind = null, limit = 8 }) {
368
+ const qs = queryList(query, queries);
369
+ if (!qs.length) return 'Provide a query: a behaviour description or an identifier.';
370
+ const shown = qs.length > 1 ? qs.map(q => `"${clip(q, 50)}"`).join(' + ') : `"${clip(qs[0], 80)}"`;
371
+ const { results, total } = intel.search.searchMany(qs, { limit: Math.min(Math.max(1, limit), 30), path: p || null, kinds: kind ? [kind] : null });
372
+ if (!results.length) return `No matches for ${shown}${p ? ` under ${p}` : ''}. Try other words for the behaviour, an identifier fragment, or outline for a map of the repository.`;
307
373
  const syms = results.map(r => intel.sym(r.id)).filter(Boolean);
308
374
  await intel.revalidate([...new Set(syms.map(s => s.path))]);
309
- const a = analyzeQuery(query);
310
- const terms = [...new Set([...a.terms, ...a.identifiers.map(x => x.toLowerCase())])];
311
- const out = [`${plural(syms.length, 'result')} for "${clip(query, 80)}"${total > syms.length ? ` (of ${total} candidates)` : ''}:`];
375
+ const terms = [...new Set(qs.flatMap(q => { const a = analyzeQuery(q); return [...a.terms, ...a.identifiers.map(x => x.toLowerCase())]; }))];
376
+ const out = [`${plural(syms.length, 'result')} for ${shown}${total > syms.length ? ` (of ${total} candidates)` : ''}:`];
312
377
  syms.forEach((s, i) => {
313
378
  out.push(`${i + 1}. ${s.qname} — ${s.kind}${s.is_test ? ', test' : ''} · ${loc(s)}`);
314
379
  if (s.sig) out.push(` ${clip(s.sig, 180)}`);
@@ -470,7 +535,7 @@ async function readRange(intel, rel, a, b, { maxLines, seen }) {
470
535
 
471
536
  async function toolSymbol(intel, { symbol, include_code = true, max_lines = 200, seen = new Set() }) {
472
537
  const matches = pickSymbol(intel, symbol);
473
- if (!matches.length) return notFound(intel, symbol);
538
+ if (!matches.length) return notFound(intel, symbol, matches);
474
539
  const s = matches[0];
475
540
  await intel.revalidate([s.path]);
476
541
  const fresh = intel.sym(s.id) ?? s;
@@ -503,8 +568,10 @@ async function toolSymbol(intel, { symbol, include_code = true, max_lines = 200,
503
568
  fam.up.sort(order); fam.down.sort(order);
504
569
  if (fam.up.length) out.push(`overrides/implements: ${fam.up.slice(0, 6).map(m => `${m.qname} (${m.path}:${m.start_line})`).join(', ')}`);
505
570
  if (fam.down.length) out.push(`overridden by (${fam.down.length}): ${fam.down.slice(0, 12).map(m => `${m.qname} (${m.path}:${m.start_line})`).join(', ')}${fam.down.length > 12 ? ', …' : ''}`);
506
- const inh = intel.inheritance(fresh.id, { max: 60 }).inherit.filter(x => !x.is_test);
571
+ const all = intel.inheritance(fresh.id, { max: 60 });
572
+ const inh = all.inherit.filter(x => !x.is_test), ext = all.extend.filter(x => !x.is_test);
507
573
  if (inh.length) out.push(`inherited unchanged by (${inh.length}, they run this code): ${inh.slice(0, 10).map(x => x.qname).join(', ')}${inh.length > 10 ? ', …' : ''}`);
574
+ if (ext.length) out.push(`extended by (${ext.length}, their own version uses this one): ${ext.slice(0, 10).map(x => x.qname).join(', ')}${ext.length > 10 ? ', …' : ''}`);
508
575
  }
509
576
  // what the body uses that is defined elsewhere, with signatures (read the listed targets rather
510
577
  // than searching for them); the bare callee list only when there is no card to show
@@ -544,7 +611,12 @@ async function toolSymbol(intel, { symbol, include_code = true, max_lines = 200,
544
611
 
545
612
  async function toolReferences(intel, { symbol, kind = 'all', include_tests = true, limit = 80, path: p = null }) {
546
613
  const matches = pickSymbol(intel, symbol);
547
- if (!matches.length) return notFound(intel, symbol);
614
+ // a Prisma model is no indexed symbol: its uses are the client's calls (unless a definition has its exact name)
615
+ if (!matches.some(m => m.name === symbol)) {
616
+ const pm = prismaModelsNamed(intel, symbol).filter(m => m.name === symbol || !matches.length);
617
+ if (pm.length) return pm.map(m => modelText(m, { max: limit })).join('\n\n');
618
+ }
619
+ if (!matches.length) return notFound(intel, symbol, matches);
548
620
  const s = matches[0];
549
621
  const all = intel.references(s.id, { kinds: kind && kind !== 'all' ? [kind] : null, includeTests: include_tests });
550
622
  const inPath = pathFilter(p);
@@ -583,13 +655,114 @@ async function toolReferences(intel, { symbol, kind = 'all', include_tests = tru
583
655
  : 'No bound references. It may be unused, an entry point, invoked by a framework/reflection, or only called through dynamic receivers.');
584
656
  if (res.elsewhere?.length) out.push(`Other references named "${s.name}" resolve elsewhere: ${res.elsewhere.map(e => `${e.n} → ${e.qname} (${e.path})`).join(', ')}.`);
585
657
  out.push(...unboundNote(res.unbound, s));
658
+ const gaps = parseGaps(intel, s);
586
659
  if (CALLABLE_KINDS.has(s.kind) && (!kind || kind === 'all' || kind === 'call') && all.total && !res.unbound?.total) {
587
- out.push(`Complete: every "${s.name}(…)" call in the indexed files is bound${res.elsewhere?.length ? ', here or to the definitions named above' : ' to this definition'}; none is left unresolved.`);
660
+ out.push(`Complete${gaps.length ? ' in the files the parser read' : ''}: every "${s.name}(…)" call in the indexed files is bound${res.elsewhere?.length ? ', here or to the definitions named above' : ' to this definition'}; none is left unresolved.`);
588
661
  }
662
+ out.push(...parseGapNote(gaps, s));
589
663
  out.push(...stringMentionNote(intel, s));
664
+ out.push(...injectionNote(intel, s));
665
+ const model = modelOf(intel, s.id);
666
+ if (model) out.push(modelText(model, { max: 12, header: false }));
667
+ return out.join('\n');
668
+ }
669
+
670
+ /** An ORM model: its table, the migrations that touch it and, for Prisma, the client's calls. */
671
+ function modelText(m, { max = 30, header = true } = {}) {
672
+ const table = `${m.schema ? `${m.schema}.` : ''}${m.table}`;
673
+ const out = [header
674
+ ? `${m.orm} model ${m.name} (${m.file}:${m.line}) — table ${table}${m.defaultName ? ' (TypeORM\'s default name)' : ''}${m.proxyName ? ` (proxy of ${m.proxyName})` : ''}`
675
+ : `ORM (${m.orm}): table ${table}${m.defaultName ? ' (TypeORM\'s default name)' : ''}${m.proxyName ? ` (proxy of ${m.proxyName})` : ''}.`];
676
+ if (m.orm === 'Prisma') {
677
+ const files = new Set(m.calls.map(c => c.file));
678
+ out.push(`Client calls (${m.calls.length} in ${plural(files.size, 'file')}; \`<client>.${m.name[0].toLowerCase() + m.name.slice(1)}.<operation>(…)\`, typed only by the generated client):`);
679
+ for (const c of m.calls.slice(0, max)) out.push(` ${c.file}:${c.line} ${c.op}${c.owner ? ` in ${c.owner}` : ''}`);
680
+ if (m.calls.length > max) out.push(` … ${m.calls.length - max} more`);
681
+ }
682
+ if (m.migrations.length) {
683
+ out.push(`Migrations that touch it (${m.migrations.length} statements in ${plural(new Set(m.migrations.map(x => x.file)).size, 'file')}):`);
684
+ for (const x of m.migrations.slice(-max)) out.push(` ${x.file}:${x.line} ${x.op}`);
685
+ if (m.migrations.length > max) out.push(` (the last ${max}; ${m.migrations.length - max} earlier)`);
686
+ } else out.push('No migration in the repository names its table.');
687
+ return out.join('\n');
688
+ }
689
+
690
+ function toolModels(intel, { filter = '' }) {
691
+ const all = collectModels(intel);
692
+ if (!all.length) return 'No ORM models recognised (Prisma, Django, SQLAlchemy/SQLModel, TypeORM).';
693
+ const f = String(filter ?? '').trim().toLowerCase();
694
+ const ms = f ? all.filter(m => m.name.toLowerCase().includes(f) || m.table.toLowerCase().includes(f)) : all;
695
+ if (!ms.length) return `No model or table matches "${filter}" (${plural(all.length, 'model')}; \`models\` lists them all).`;
696
+ if (ms.length <= 3) return ms.map(m => modelText(m)).join('\n\n');
697
+ const out = [`${plural(ms.length, 'model')}${f ? ` matching "${filter}"` : ''}:`];
698
+ for (const m of ms.slice(0, 200)) out.push(` ${m.orm.padEnd(10)} ${m.name.padEnd(28)} table ${(m.schema ? `${m.schema}.` : '') + m.table} ${m.file}:${m.line} ${plural(m.migrations.length, 'migration statement')}${m.orm === 'Prisma' ? `, ${plural(m.calls.length, 'client call')}` : ''}`);
699
+ if (ms.length > 200) out.push(` … ${ms.length - 200} more (pass part of a name)`);
590
700
  return out.join('\n');
591
701
  }
592
702
 
703
+ /** Dependency injection around a definition: what it provides and to whom, or, for a token or an injected type, its providers. */
704
+ function injectionNote(intel, s, max = 8) {
705
+ const out = [];
706
+ const into = (js) => js.map(j => `${j.owner?.qname ?? 'module level'}${j.param ? `.${j.param}` : ''} ${j.file}:${j.line}`);
707
+ for (const p of providedBy(intel, s.id)) {
708
+ const xs = into(p.into);
709
+ const to = xs.length ? ` and injected into ${xs.slice(0, max).join(', ')}${xs.length > max ? ', …' : ''}` : ', injected nowhere in the indexed code';
710
+ if (p.types) {
711
+ // a bean that loses to another at some points (@Primary, a name) is said so, not "injected nowhere"
712
+ const lost = p.into.length ? [] : collectInjection(intel).injections.filter(j => j.others?.some(o => o.target === p.target));
713
+ const to2 = lost.length ? `, not chosen at ${into(lost).slice(0, max).join(', ')} (${lost[0].from[0]?.target?.name ?? '?'} is: ${lost[0].why.split(',')[0]})` : to;
714
+ out.push(`Dependency injection: a Spring bean (${p.how}${p.how === '@Bean' ? '' : ` ${p.name}`}, ${p.file}:${p.line})${p.primary ? ', @Primary,' : ''}${to2}${p.exact ? '' : ' [type matched by its name]'}.`);
715
+ }
716
+ else out.push(`Dependency injection: provided as ${p.token} (${p.how}, ${p.file}:${p.line})${to}${p.exact ? '' : ' [token matched by its text]'}.`);
717
+ }
718
+ const t = tokenUses(intel, s.id);
719
+ const spring = t.injections.some(j => j.framework === 'spring');
720
+ const by = (p) => `${p.how} ${p.target?.qname ?? clip(p.targetText, 40)} (${p.file}:${p.line})`;
721
+ if (spring) {
722
+ const points = t.injections.slice(0, max).map(j => `${j.owner?.qname ?? '?'}.${j.param} ${j.file}:${j.line} ← ${j.from.length ? j.from.map(p => p.target?.name ?? p.targetText).join(' / ') : 'no bean in the indexed code'}${j.why && j.why !== 'by type' ? ` (${j.why})` : ''}`);
723
+ out.push(`Dependency injection (Spring): ${plural(t.injections.length, 'injection point')} of type ${s.name}${t.providers.length ? `, filled by ${t.providers.slice(0, max).map(by).join(', ')}` : ', no bean of it in the indexed code (a library or auto-configured bean)'}: ${points.join('; ')}${t.injections.length > max ? '; …' : ''}.`);
724
+ } else if (t.providers.length) out.push(`Dependency injection: ${s.name} is a token, provided by ${t.providers.slice(0, max).map(by).join(', ')}; ${plural(t.injections.length, 'injection point')}.`);
725
+ return out;
726
+ }
727
+
728
+ function toolInject(intel, { filter = '' }) {
729
+ const { providers, injections } = collectInjection(intel);
730
+ const f = String(filter ?? '').trim().toLowerCase();
731
+ // rows: what is asked for (a NestJS token, a Spring type), with what provides it and where it is injected;
732
+ // a Spring bean nobody injects (a controller, a configuration class) is counted, not listed
733
+ const nest = providers.filter(p => !p.types);
734
+ const keys = [...new Set([...nest, ...injections].map(x => x.key))];
735
+ const named = (p) => (p.target?.qname ?? p.targetText).toLowerCase();
736
+ const rows = keys.map(key => {
737
+ const js = injections.filter(j => j.key === key);
738
+ return { key, token: (nest.find(p => p.key === key) ?? js[0]).token, ps: [...new Set([...nest.filter(p => p.key === key), ...js.flatMap(j => j.from)])], js };
739
+ }).filter(r => !f || r.token.toLowerCase().includes(f) || r.ps.some(p => named(p).includes(f)) || r.js.some(j => (j.owner?.qname ?? '').toLowerCase().includes(f)));
740
+ const frameworks = [nest.length || injections.some(j => !j.framework) ? 'NestJS: provide/use… and @Inject' : null, providers.some(p => p.types) ? 'Spring: beans and their injection points' : null].filter(Boolean);
741
+ if (!keys.length) return providers.length ? `${plural(providers.length, 'Spring bean')} and no injection point recognised.` : 'No dependency-injection providers or injection points recognised (NestJS, Spring).';
742
+ if (!rows.length) return `Nothing matches "${filter}" (${plural(keys.length, 'injected type or token', 'injected types or tokens')} in the repository; \`inject\` lists them all).`;
743
+ const out = [`${plural(rows.length, 'injected type or token', 'injected types or tokens')}${f ? ` matching "${filter}"` : ''} (${frameworks.join('; ')}):`];
744
+ const where = (p) => p.target ? `${p.target.qname} ${p.target.path}:${p.target.start_line}` : clip(p.targetText, 60);
745
+ for (const r of rows.slice(0, 100)) {
746
+ out.push(` ${r.token}${r.ps.some(p => !p.exact) || r.js.some(j => !j.exact) ? ' [matched by its text]' : ''}`);
747
+ for (const p of r.ps) out.push(` provided ${p.how.padEnd(11)} ${where(p)}${p.primary && p.types ? ' @Primary' : ''}${p.conditions?.length ? ` ${p.conditions.join(' ')}` : ''}${!p.types ? ` (${p.file}:${p.line})` : ''}`);
748
+ if (!r.ps.length) out.push(` provided ${injectionSource(intel, r.key, r.js[0])}`);
749
+ for (const j of r.js) out.push(` injected ${j.owner?.qname ?? 'module level'}${j.param ? `.${j.param}` : ''}${j.type ? `: ${clip(j.type, 40)}` : ''} (${j.file}:${j.line})${j.why && (j.from.length !== 1 || j.why !== 'by type') ? ` ← ${j.from.length === 1 ? `${j.from[0].target?.name ?? j.from[0].targetText}, ` : ''}${j.why}` : ''}`);
750
+ }
751
+ if (rows.length > 100) out.push(` … ${rows.length - 100} more (pass part of a name to narrow)`);
752
+ const idle = providers.filter(p => p.types && !injections.some(j => j.from.includes(p)));
753
+ if (idle.length && !f) out.push(`${plural(idle.length, 'Spring bean')} injected nowhere by type in the indexed code (controllers, configuration, beans used by the framework or a library): \`inject <name>\` shows one.`);
754
+ return out.join('\n');
755
+ }
756
+
757
+ /** Where a token with no `provide:` comes from: a class provides itself; otherwise a library or dynamic module. */
758
+ function injectionSource(intel, key, j = null) {
759
+ if (j?.framework === 'spring') return j.why.startsWith('@Qualifier') ? `no bean of this type named so in the indexed code (${j.why}): a library or auto-configured bean` : 'no bean of this type in the indexed code: a library or auto-configured bean, or one declared in XML';
760
+ const id = key.startsWith('id:') ? Number(key.slice(3)) : null;
761
+ const s = id != null ? intel.sym(id) : null;
762
+ if (s && TYPE_KINDS.has(s.kind)) return `the class itself (a class is its own token when a module lists it) ${loc(s)}`;
763
+ return 'not in the indexed code (a library token such as REQUEST or CACHE_MANAGER, or a dynamic module)';
764
+ }
765
+
593
766
  /** `path` argument → predicate on repository-relative paths (a directory prefix or one file). */
594
767
  function pathFilter(p) {
595
768
  if (!p) return () => true;
@@ -648,7 +821,7 @@ function stringMentionNote(intel, s, max = 8) {
648
821
 
649
822
  async function toolCallGraph(intel, { symbol, direction = 'both', depth = 2, limit = 40, include_tests = true }) {
650
823
  const matches = pickSymbol(intel, symbol);
651
- if (!matches.length) return notFound(intel, symbol);
824
+ if (!matches.length) return notFound(intel, symbol, matches);
652
825
  const s = matches[0];
653
826
  const out = [`Call graph of ${s.qname} (${s.kind}) ${loc(s)}`];
654
827
  if (direction === 'callers' || direction === 'both') {
@@ -706,8 +879,10 @@ async function toolCallGraph(intel, { symbol, direction = 'both', depth = 2, lim
706
879
  if (!lines.length) out.push(' (no bound callers — entry point, framework-invoked, or dynamic dispatch)');
707
880
  if (cut) out.push(` … more callers not shown (raise limit or lower depth)`);
708
881
  const unbound = unboundNote(intel.unboundFor(s.id), s, 5);
882
+ const gaps = parseGaps(intel, s);
709
883
  if (unbound.length) out.push(...unbound.map(l => ' ' + l));
710
- else if (!cut) out.push(` Complete: every call the index binds is listed${depth > 1 ? ' at each level' : ''}, and no same-name call with a receiver of unknown type could reach ${s.name}.`);
884
+ else if (!cut) out.push(` Complete${gaps.length ? ' in the files the parser read' : ''}: every call the index binds is listed${depth > 1 ? ' at each level' : ''}, and no same-name call with a receiver of unknown type could reach ${s.name}.`);
885
+ out.push(...parseGapNote(gaps, s).map(l => ' ' + l));
711
886
  }
712
887
  if (direction === 'callees' || direction === 'both') {
713
888
  out.push('callees:');
@@ -782,7 +957,7 @@ async function toolImpact(intel, { symbols = [], files = [], diff = false, depth
782
957
  overrides.sort((a, b) => pathRank(a.s.path) - pathRank(b.s.path) || a.s.path.localeCompare(b.s.path));
783
958
  // subclasses that inherit a changed member without redeclaring it run the changed code too
784
959
  const inheritors = new Map();
785
- for (const id of seeds) for (const x of intel.inheritance(id).inherit) inheritors.set(x.id, x);
960
+ for (const id of seeds) { const h = intel.inheritance(id); for (const x of [...h.inherit, ...h.extend]) inheritors.set(x.id, x); }
786
961
  for (const t of intel.conventionTests([...seedFiles])) if (!tests.has(t)) tests.set(t, ['(by naming convention)']);
787
962
  for (const t of intel.conventionTests([...new Set([...inheritors.values()].map(x => x.path))])) if (!tests.has(t)) tests.set(t, ['(tests a subclass that inherits it)']);
788
963
  for (const x of inheritors.values()) if (x.is_test) { addTest(x.path, x.name); inheritors.delete(x.id); }
@@ -816,7 +991,7 @@ async function toolImpact(intel, { symbols = [], files = [], diff = false, depth
816
991
  if (overrides.length > 15) out.push(` … ${overrides.length - 15} more`);
817
992
  }
818
993
  if (inheritors.size) {
819
- out.push(`inherited by (these subclasses run the changed code too): ${inheritors.size}`);
994
+ out.push(`inherited or extended by (these subclasses run the changed code too): ${inheritors.size}`);
820
995
  for (const x of [...inheritors.values()].slice(0, 15)) out.push(` ${x.qname} ${loc(x)}`);
821
996
  if (inheritors.size > 15) out.push(` … ${inheritors.size - 15} more`);
822
997
  }
@@ -860,8 +1035,8 @@ async function toolImpact(intel, { symbols = [], files = [], diff = false, depth
860
1035
  return out.join('\n');
861
1036
  }
862
1037
 
863
- async function toolCheck(intel, { files = null, base = 'HEAD' }) {
864
- const r = await checkChanges(intel, { base, files: files?.length ? files : null });
1038
+ async function toolCheck(intel, { files = null, base = 'HEAD', by_name: byName = false }) {
1039
+ const r = await checkChanges(intel, { base, files: files?.length ? files : null, byName });
865
1040
  if (r.error) return r.error;
866
1041
  if (!r.files) return `No changed source files against ${base}${files?.length ? ` under ${files.join(', ')}` : ''}.`;
867
1042
  const out = [`Checked ${plural(r.files, 'changed file')} against ${base}.`];
@@ -894,6 +1069,7 @@ async function toolCheck(intel, { files = null, base = 'HEAD' }) {
894
1069
  for (const h of r.inherited ?? []) {
895
1070
  const members = h.members.join(', ');
896
1071
  if (h.inherit.length) out.push(`• subclasses of ${h.type} that inherit ${members} run the changed code too (${h.inherit.length}): ${h.inherit.slice(0, 8).map(x => `${x.qname} (${x.path})`).join(', ')}${h.inherit.length > 8 ? `, … ${h.inherit.length - 8} more` : ''}`);
1072
+ if (h.extend?.length) out.push(`• subclasses of ${h.type} whose own ${members} uses this one run the changed code too (${h.extend.length}): ${h.extend.slice(0, 8).map(x => `${x.qname} (${x.path})`).join(', ')}${h.extend.length > 8 ? `, … ${h.extend.length - 8} more` : ''}`);
897
1073
  if (h.override.length) out.push(`• overridden, so not reached by the change: ${h.override.slice(0, 8).map(x => `${x.qname} (${x.path})`).join(', ')}${h.override.length > 8 ? `, … ${h.override.length - 8} more` : ''}`);
898
1074
  }
899
1075
  if (r.cases.length) {
@@ -905,10 +1081,226 @@ async function toolCheck(intel, { files = null, base = 'HEAD' }) {
905
1081
  out.push(`test files that exercise the change (${r.tests.length}): ${r.tests.slice(0, 12).join(', ')}${r.tests.length > 12 ? ', …' : ''}`);
906
1082
  for (const c of r.commands) out.push(` run: ${clip(c, 400)}`);
907
1083
  } else out.push('tests: none found that exercise the changed code.');
1084
+ if (r.byName?.length) {
1085
+ out.push(`test files that name a changed module in a string (they may reach it by a lookup the index does not see; run them for more certainty) (${r.byName.length}): ${r.byName.slice(0, 12).join(', ')}${r.byName.length > 12 ? ', …' : ''}`);
1086
+ for (const c of r.byNameCommands) out.push(` run: ${clip(c, 400)}`);
1087
+ }
908
1088
  out.push(problems ? `${plural(problems, 'problem')} found.` : 'No problems found in the changed code (this check does not replace running the tests).');
909
1089
  return out.join('\n');
910
1090
  }
911
1091
 
1092
+ // explore: code is denser than prose, so the budget counts fewer characters per token than outline's 4
1093
+ const EXPLORE_CHARS_PER_TOKEN = 3.5;
1094
+ const EXPLORE_PRIMARY = 3;
1095
+ const EXPLORE_SECONDARY = 5;
1096
+ const CALLER_KINDS = ['call', 'new', 'value', 'decorator'];
1097
+
1098
+ /**
1099
+ * Definitions to centre an exploration on, most relevant first: the symbols named, identifiers in the
1100
+ * query that name a definition, then the search results for the query (library code before tests and
1101
+ * examples, callables and types before plain values). Each comes with why it was picked.
1102
+ */
1103
+ function exploreSeeds(intel, { query, queries = [query], symbols, path: p }) {
1104
+ const seeds = [], ids = new Set(), missing = [];
1105
+ const add = (s, why) => { if (s && !ids.has(s.id)) { ids.add(s.id); seeds.push({ s, why }); } };
1106
+ for (const t of symbols) {
1107
+ const m = pickSymbol(intel, t);
1108
+ if (m.length) add(m[0], 'named'); else missing.push(t);
1109
+ }
1110
+ if (query) {
1111
+ // a route in the query (`POST /api/login`): its handler, found through the framework's registration
1112
+ const routes = routesInQuery(query);
1113
+ if (routes.length) {
1114
+ const all = collectRoutes(intel);
1115
+ for (const q of routes) for (const r of matchRoutes(all, q).slice(0, 3)) if (r.handler) add(intel.sym(r.handler.id), `handles ${r.method} ${r.path}`);
1116
+ }
1117
+ // an issue: the definitions where the most (and rarest) of the names it uses meet
1118
+ if (isIssueText(query)) for (const r of issueSeeds(intel, query, { path: p || null })) add(intel.sym(r.id), `issue terms: ${r.terms.slice(0, 4).join(', ')}`);
1119
+ const a = analyzeQuery(query);
1120
+ for (const id of a.identifiers) {
1121
+ if (id.length < 3) continue;
1122
+ // a bare word that happens to name something (`user`, `config`) is not a lookup
1123
+ if (a.natural && /^[a-z]+$/.test(id)) continue;
1124
+ // the definition must carry the name as written: `CLAUDE.md` is a file, not a variable `md`
1125
+ const norm = id.replace(/::|#|->/g, '.');
1126
+ let m = pickSymbol(intel, id).filter(x => x.name === norm || x.qname === norm || x.qname.endsWith('.' + norm));
1127
+ // a keyword in capitals (`ALTER`, `PIVOT`) that names an entry of a large table of values (a
1128
+ // token enum) is the language being talked about, not a definition to look at
1129
+ if (/^[A-Z][A-Z0-9_]*$/.test(id)) m = m.filter(x => !(VALUE_KINDS.has(x.kind) && x.parent_id != null && largeTable(intel, x.parent_id)));
1130
+ if (m.length) add(m[0], 'named in the query');
1131
+ }
1132
+ const { results } = intel.search.searchMany(queries.length ? queries : [query], { limit: 16, path: p || null });
1133
+ const wantTests = /\btests?\b|\bspec\b/i.test(query);
1134
+ const wantStyles = /\b(css|scss|less|styles?|stylesheet|selector)\b/i.test(query);
1135
+ const wantDocs = WANTS_DOCS.test(query);
1136
+ const found = results.map(r => intel.sym(r.id)).filter(Boolean)
1137
+ .filter(s => s.kind !== 'parameter' && (wantTests || !s.is_test) && (wantStyles || s.lang !== 'css') && (wantDocs || s.kind !== 'section'));
1138
+ // stable re-order: rank keeps search order within each tier
1139
+ const tier = (s) => pathRank(s.path) * 2 + (VALUE_KINDS.has(s.kind) ? 1 : 0);
1140
+ found.map((s, i) => ({ s, i })).sort((x, y) => tier(x.s) - tier(y.s) || x.i - y.i).forEach(({ s }) => add(s, 'search'));
1141
+ }
1142
+ // a member whose class is already a seed is shown inside it; a class whose member is a seed
1143
+ // ranked higher only repeats it
1144
+ const kept = seeds.filter(({ s }) => !(s.parent_id != null && ids.has(s.parent_id) && seeds.findIndex(x => x.s.id === s.parent_id) < seeds.findIndex(x => x.s.id === s.id)));
1145
+ return { seeds: kept, missing };
1146
+ }
1147
+
1148
+ function toolRoutes(intel, { filter = '' }) {
1149
+ const all = collectRoutes(intel);
1150
+ const f = String(filter ?? '').trim();
1151
+ const q = f ? routesInQuery(f.startsWith('/') || /^[A-Z]+\s+\//.test(f) ? f : '/' + f) : [];
1152
+ const rows = f ? (q.length ? q.flatMap(x => matchRoutes(all, x)) : all.filter(r => r.path.includes(f))) : all;
1153
+ if (!all.length) return 'No HTTP routes recognised (FastAPI, Flask, NestJS, Spring, Express/Fastify/Koa/Hono, Gin/Echo/chi, net/http).';
1154
+ if (!rows.length) return `No route matches "${f}" (${plural(all.length, 'route')} in the repository; \`routes\` lists them all).`;
1155
+ const out = [`${plural(rows.length, 'route')}${f ? ` matching "${f}"` : ''}:`];
1156
+ for (const r of rows.slice(0, 200)) {
1157
+ const h = r.handler ? `${r.handler.qname} ${r.handler.path}:${r.handler.start_line}` : `inline handler at ${r.file}:${r.line}`;
1158
+ out.push(` ${r.method.padEnd(7)} ${r.path.padEnd(40)} → ${h}`);
1159
+ }
1160
+ if (rows.length > 200) out.push(` … ${rows.length - 200} more (pass part of a path to narrow)`);
1161
+ return out.join('\n');
1162
+ }
1163
+
1164
+ /** Direct callers of a definition outside tests, and the tests that call it. */
1165
+ function exploreCallers(intel, s) {
1166
+ const { nodes, fileLevel, truncated, overflow } = intel.dependents([s.id], { depth: 1, maxNodes: 80, kinds: CALLER_KINDS });
1167
+ const code = [], tests = [];
1168
+ for (const [id, info] of nodes) {
1169
+ const c = intel.sym(id);
1170
+ if (!c) continue;
1171
+ const site = info.sites?.[0] ?? { path: c.path, line: c.start_line };
1172
+ (c.is_test ? tests : code).push({ c, info, site });
1173
+ }
1174
+ code.sort((a, b) => pathRank(a.c.path) - pathRank(b.c.path) || b.info.conf - a.info.conf || a.c.path.localeCompare(b.c.path) || a.site.line - b.site.line);
1175
+ const modules = [], testModules = [];
1176
+ for (const [file, m] of fileLevel) (isTestPath(intel, file) ? testModules : modules).push({ file, line: m.sites[0], conf: m.conf });
1177
+ return { code, tests, modules, testModules, more: truncated ? overflow : 0 };
1178
+ }
1179
+
1180
+ const isTestPath = (intel, file) => Boolean(intel.store.get('SELECT is_test FROM files WHERE path = ?', file)?.is_test);
1181
+
1182
+ async function toolExplore(intel, { query = '', queries = null, symbols = [], path: p = null, max_tokens = 2000 }) {
1183
+ const qs = queryList(query, queries);
1184
+ const q = qs[0] ?? '';
1185
+ const named = (Array.isArray(symbols) ? symbols : String(symbols).split(/[\s,]+/)).map(t => String(t).trim()).filter(Boolean);
1186
+ if (!q && !named.length) return 'Provide a query (the task, question or bug in words) and/or symbols to centre on.';
1187
+ const budget = Math.round(Math.min(Math.max(500, Number(max_tokens) || 2000), 6000) * EXPLORE_CHARS_PER_TOKEN);
1188
+ const { seeds, missing } = exploreSeeds(intel, { query: q, queries: qs, symbols: named, path: p });
1189
+ if (!seeds.length) {
1190
+ const what = q ? `"${clip(q, 80)}"${p ? ` under ${p}` : ''}` : named.map(t => `"${t}"`).join(', ');
1191
+ return `Nothing in the index matches ${what}. Try other words for the behaviour, an identifier, or ${tn('outline')} for a map of the repository.`;
1192
+ }
1193
+ await intel.revalidate([...new Set(seeds.slice(0, EXPLORE_PRIMARY + EXPLORE_SECONDARY).map(x => x.s.path))]);
1194
+ const fresh = seeds.map(x => ({ ...x, s: intel.sym(x.s.id) ?? x.s }));
1195
+ // fewer definitions shown whole when the budget is small: each needs room for its code
1196
+ const nPrimary = Math.max(1, Math.min(EXPLORE_PRIMARY, Math.floor(budget / 1200)));
1197
+ const primary = fresh.slice(0, nPrimary), secondary = fresh.slice(nPrimary, nPrimary + EXPLORE_SECONDARY);
1198
+ const seen = new Set(fresh.slice(0, nPrimary + EXPLORE_SECONDARY).map(x => x.s.id));
1199
+
1200
+ // 1. everything but the code, per definition: compact lines, so their cost is known up front
1201
+ const testFiles = new Set(), testCases = [];
1202
+ const sections = primary.map(({ s, why }, i) => {
1203
+ const head = `## ${i + 1}. ${s.qname} — ${s.kind}${s.exported ? ', exported' : ''}${s.is_test ? ', test' : ''} · ${loc(s)}${why === 'search' ? '' : ` (${why})`}`;
1204
+ const facts = [];
1205
+ if (s.parent_id) { const par = intel.sym(s.parent_id); if (par) facts.push(`member of ${par.qname} (${par.kind}) ${loc(par)}`); }
1206
+ if (CALLABLE_KINDS.has(s.kind) || TYPE_KINDS.has(s.kind)) {
1207
+ const { code, tests, modules, testModules, more } = exploreCallers(intel, s);
1208
+ const total = code.length + modules.length + more;
1209
+ if (total) {
1210
+ const shown = code.slice(0, 6).map(x => `${x.c.qname} ${x.site.path}:${x.site.line}${x.info.conf < 0.9 ? ` [${confWord(x.info.conf)}]` : ''}`);
1211
+ for (const m of modules.slice(0, Math.max(0, 6 - shown.length))) shown.push(`module level ${m.file}:${m.line}`);
1212
+ facts.push(`called by (${total}): ${shown.join(', ')}${total > shown.length ? `, … (${tn('call_graph')} / ${tn('find_references')} for all)` : ''}`);
1213
+ } else if (why.startsWith('handles ')) facts.push(`called by: the web framework, for ${why.slice(8)}`);
1214
+ else if (!s.is_test) facts.push('called by: no bound callers (entry point, framework-invoked, or dynamic dispatch)');
1215
+ const u = intel.unboundFor(s.id);
1216
+ if (u.plausible.length) facts.push(`possibly also called at: ${u.plausible.slice(0, 3).map(r => `${r.path}:${r.line}`).join(', ')}${u.plausible.length > 3 ? ', …' : ''} (receiver type unknown)`);
1217
+ for (const t of tests) { testFiles.add(t.c.path); testCases.push(`${t.c.qname} (${t.site.path}:${t.site.line})`); }
1218
+ for (const m of testModules) { testFiles.add(m.file); testCases.push(`${m.file}:${m.line}`); }
1219
+ }
1220
+ if (s.kind === 'method' && !s.is_static) {
1221
+ const fam = intel.methodFamily(s);
1222
+ if (fam.up.length) facts.push(`overrides/implements: ${fam.up.slice(0, 4).map(m => `${m.qname} (${m.path}:${m.start_line})`).join(', ')}`);
1223
+ if (fam.down.length) facts.push(`overridden by (${fam.down.length}): ${fam.down.slice(0, 6).map(m => `${m.qname} (${m.path}:${m.start_line})`).join(', ')}${fam.down.length > 6 ? ', …' : ''}`);
1224
+ }
1225
+ if (TYPE_KINDS.has(s.kind)) {
1226
+ const { direct, indirect } = subtypeTree(intel, s.id);
1227
+ if (direct.length) facts.push(`subtypes/implementations (${direct.length + indirect.length}): ${[...direct, ...indirect].slice(0, 8).map(x => `${x.qname} (${x.path}:${x.start_line})`).join(', ')}${direct.length + indirect.length > 8 ? ', …' : ''}`);
1228
+ }
1229
+ // dependency injection: what this class receives through @Inject(TOKEN), and what it provides
1230
+ const cls = s.kind === 'class' ? s : (s.parent_id ? intel.sym(s.parent_id) : null);
1231
+ if (cls?.kind === 'class') {
1232
+ const recv = injectedInto(intel, cls.id).filter(j => s.kind === 'class' || (j.line >= s.start_line && j.line <= s.end_line));
1233
+ if (recv.length) facts.push(`receives by injection: ${recv.slice(0, 4).map(j => `${j.param ?? '?'} ← ${j.token}, ${j.from.length ? j.from.slice(0, 2).map(p => `${p.how} ${p.target?.qname ?? clip(p.targetText, 30)} (${p.file}:${p.line})`).join(' / ') : injectionSource(intel, j.key, j)}${j.why && j.why !== 'by type' && j.from.length ? ` [${j.why}]` : ''}`).join('; ')}${recv.length > 4 ? `; … ${recv.length - 4} more` : ''}`);
1234
+ }
1235
+ const model = modelOf(intel, s.id);
1236
+ if (model) facts.push(`ORM model (${model.orm}): table ${(model.schema ? `${model.schema}.` : '') + model.table}; ${model.migrations.length ? `migrations touching it: ${model.migrations.slice(-3).map(x => `${x.file}:${x.line} ${x.op}`).join(', ')}${model.migrations.length > 3 ? `, … (${model.migrations.length} statements; find_references lists them)` : ''}` : 'no migration names it'}`);
1237
+ const gives = providedBy(intel, s.id);
1238
+ if (gives.length) facts.push(`provided by injection as ${gives.slice(0, 3).map(p => `${p.types ? `a Spring bean (${p.how})` : p.token} to ${p.into.length ? p.into.slice(0, 3).map(j => `${j.owner?.qname ?? '?'} (${j.file}:${j.line})`).join(', ') + (p.into.length > 3 ? `, … ${p.into.length - 3} more` : '') : 'no injection point'}`).join('; ')}`);
1239
+ if (!s.is_test) for (const t of testsUsing(intel, s.id)) {
1240
+ testFiles.add(t.path);
1241
+ const label = t.qname === '(module)' ? `${t.path}:${t.line}` : `${t.qname} (${t.path}:${t.line})`;
1242
+ if (!testCases.includes(label)) testCases.push(label);
1243
+ }
1244
+ return { s, head, facts };
1245
+ });
1246
+ for (const t of intel.conventionTests([...new Set(primary.filter(x => !x.s.is_test).map(x => x.s.path))])) testFiles.add(t);
1247
+
1248
+ // 2. what the shown code uses, defined elsewhere: one shared card, seeds and repeats left out
1249
+ const card = [], moreNames = [];
1250
+ for (const { s } of primary) {
1251
+ const { rows, more } = definitionsUsed(intel, s.path, s.start_line, s.end_line, { max: Math.max(3, Math.floor(10 / primary.length) + 1), skipIds: seen, skipAncestorsOf: s.id });
1252
+ for (const d of rows) {
1253
+ seen.add(d.id);
1254
+ card.push(` ${d.qname} ${d.path}:${d.start_line}${d.byName ? ' (by name)' : ''} ${clip(String(d.sig || `${d.kind} ${d.name}`).replace(/\s+/g, ' '), 90)}`);
1255
+ }
1256
+ for (const d of more) if (!seen.has(d.id)) { seen.add(d.id); moreNames.push(d.qname); }
1257
+ }
1258
+ const tail = [];
1259
+ if (card.length) tail.push('Defined elsewhere (used by the code above):', ...card);
1260
+ if (moreNames.length) tail.push(` … ${moreNames.length} more: ${moreNames.slice(0, 10).join(', ')}${moreNames.length > 10 ? ', …' : ''}`);
1261
+ if (testFiles.size || testCases.length) {
1262
+ tail.push(`Tests (${plural(testFiles.size, 'file')}): ${testCases.length ? testCases.slice(0, 5).join(', ') + (testCases.length > 5 ? ', …' : '') : [...testFiles].slice(0, 5).join(', ')}`);
1263
+ for (const c of testCommands(intel.root, [...testFiles].slice(0, 12))) tail.push(` run: ${clip(c, 300)}`);
1264
+ } else if (primary.some(x => !x.s.is_test)) tail.push('Tests: none found that call this code (consider adding one).');
1265
+ if (secondary.length) {
1266
+ tail.push('Also relevant (not shown):');
1267
+ for (const { s } of secondary) tail.push(` ${s.qname} — ${s.kind} · ${loc(s)}${s.sig ? ` ${clip(String(s.sig).replace(/\s+/g, ' '), 90)}` : ''}`);
1268
+ }
1269
+ if (missing.length) tail.push(`Not found: ${missing.map(t => `"${t}"`).join(', ')}.`);
1270
+
1271
+ // 3. the code, in the budget that is left, in order of relevance; what does not fit is named
1272
+ const fixed = [...sections.flatMap(x => [x.head, ...x.facts]), ...tail].join('\n').length + 200;
1273
+ let left = budget - fixed;
1274
+ const out = [`Exploring ${q ? `"${clip(q, 80)}"` : named.join(', ')}${p ? ` under ${p}` : ''}: ${plural(primary.length, 'definition')} shown${secondary.length ? `, ${secondary.length} more listed` : ''}.`];
1275
+ const cut = [];
1276
+ sections.forEach(({ s, head, facts }, i) => {
1277
+ out.push('', head, ...facts);
1278
+ const lines = intel.fileLines(s.path);
1279
+ const span = s.end_line - s.start_line + 1;
1280
+ // the rest of the budget is shared among the definitions not yet shown
1281
+ const share = Math.floor(left / (sections.length - i));
1282
+ let n = 0, used = 0;
1283
+ for (let ln = s.start_line; ln <= s.end_line && lines; ln++) {
1284
+ const w = Math.min(lines[ln - 1]?.length ?? 0, 220) + 8;
1285
+ if (used + w > share) break;
1286
+ used += w; n++;
1287
+ }
1288
+ if (n >= Math.min(span, 3)) {
1289
+ out.push(codeBlock(lines, s.start_line, s.end_line, { maxLines: n }));
1290
+ if (n < span) cut.push(`${s.path}:${s.start_line + n}-${s.end_line}`);
1291
+ left -= used;
1292
+ } else {
1293
+ if (s.sig) out.push(` ${clip(String(s.sig).replace(/\s+/g, ' '), 200)}`);
1294
+ cut.push(s.qname);
1295
+ }
1296
+ });
1297
+ if (tail.length) out.push('', ...tail);
1298
+ out.push(cut.length
1299
+ ? `Left out to fit the budget: ${cut.join(', ')} — ${surface === 'cli' ? `\`read ${cut.slice(0, 3).join(' ')}\`` : `read_code with ${cut.length === 1 ? 'it' : 'them'}`}, or raise max_tokens.`
1300
+ : `Next: ${surface === 'cli' ? '`read <name>…`' : 'read_code'} for the names above; ${tn('change_impact')} before changing a signature others rely on.`);
1301
+ return out.join('\n');
1302
+ }
1303
+
912
1304
  /** Every definition of an indexed file with its signature and line range, within a token budget. */
913
1305
  function fileOutline(intel, rel, maxTokens = 1500) {
914
1306
  const rows = intel.store.all(`SELECT s.id, s.qname, s.name, s.kind, s.parent_id, s.start_line, s.end_line, s.sig, s.doc FROM symbols s JOIN files f ON f.id = s.file_id WHERE f.path = ? ORDER BY s.start_line`, rel);