graph-indexer 2.1.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/CHANGELOG.md +260 -1
  2. package/README.md +232 -312
  3. package/SECURITY.md +37 -59
  4. package/bin/graph-indexer.mjs +251 -0
  5. package/integrations/claude-code/.claude-plugin/plugin.json +8 -0
  6. package/integrations/claude-code/.mcp.json +8 -0
  7. package/integrations/claude-code/agents/code-structure.md +22 -0
  8. package/integrations/claude-code/hooks/gi-hook.mjs +99 -0
  9. package/integrations/claude-code/hooks/hooks.json +16 -0
  10. package/integrations/opencode/graph-indexer.js +124 -0
  11. package/package.json +32 -109
  12. package/src/cli/helper.mjs +67 -0
  13. package/src/cli/hook.mjs +356 -0
  14. package/src/cli/init.mjs +255 -0
  15. package/src/cli/resident.mjs +175 -0
  16. package/src/index/discover.mjs +130 -0
  17. package/src/index/fingerprint.mjs +30 -0
  18. package/src/index/graph.mjs +61 -0
  19. package/src/index/indexer.mjs +476 -0
  20. package/src/index/modules.mjs +331 -0
  21. package/src/index/resolver.mjs +954 -0
  22. package/src/mcp/render.mjs +60 -0
  23. package/src/mcp/server.mjs +147 -0
  24. package/src/mcp/tools.mjs +973 -0
  25. package/src/parse/arity.mjs +152 -0
  26. package/src/parse/extract.mjs +1031 -0
  27. package/src/parse/lang/csharp.mjs +106 -0
  28. package/src/parse/lang/ecma.mjs +438 -0
  29. package/src/parse/lang/go.mjs +194 -0
  30. package/src/parse/lang/jvm.mjs +118 -0
  31. package/src/parse/lang/kotlin.mjs +97 -0
  32. package/src/parse/lang/misc.mjs +115 -0
  33. package/src/parse/lang/native.mjs +126 -0
  34. package/src/parse/lang/python.mjs +153 -0
  35. package/src/parse/lang/rust.mjs +157 -0
  36. package/src/parse/lang/scripting.mjs +219 -0
  37. package/src/parse/languages.mjs +48 -0
  38. package/src/parse/runtime.mjs +56 -0
  39. package/src/query/check.mjs +343 -0
  40. package/src/query/facts.mjs +323 -0
  41. package/src/query/intel.mjs +680 -0
  42. package/src/query/read.mjs +81 -0
  43. package/src/query/testcmd.mjs +119 -0
  44. package/src/query/textsearch.mjs +160 -0
  45. package/src/search/search.mjs +210 -0
  46. package/src/search/tokenize.mjs +188 -0
  47. package/src/store/db.mjs +169 -0
  48. package/src/util/paths.mjs +24 -0
  49. package/vendor/grammars/bash.wasm.gz +0 -0
  50. package/vendor/grammars/c.wasm.gz +0 -0
  51. package/vendor/grammars/c_sharp.wasm.gz +0 -0
  52. package/vendor/grammars/cpp.wasm.gz +0 -0
  53. package/vendor/grammars/css.wasm.gz +0 -0
  54. package/vendor/grammars/go.wasm.gz +0 -0
  55. package/vendor/grammars/java.wasm.gz +0 -0
  56. package/vendor/grammars/javascript.wasm.gz +0 -0
  57. package/vendor/grammars/kotlin.wasm.gz +0 -0
  58. package/vendor/grammars/manifest.json +148 -0
  59. package/vendor/grammars/php.wasm.gz +0 -0
  60. package/vendor/grammars/python.wasm.gz +0 -0
  61. package/vendor/grammars/ruby.wasm.gz +0 -0
  62. package/vendor/grammars/rust.wasm.gz +0 -0
  63. package/vendor/grammars/scala.wasm.gz +0 -0
  64. package/vendor/grammars/tsx.wasm.gz +0 -0
  65. package/vendor/grammars/typescript.wasm.gz +0 -0
  66. package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
  67. package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  68. package/cli-ui.mjs +0 -78
  69. package/colbert.mjs +0 -168
  70. package/config.mjs +0 -494
  71. package/daemon-ctl.mjs +0 -175
  72. package/daemon-lock.mjs +0 -69
  73. package/embedders/python/mlx_embed_server.py +0 -61
  74. package/embedders/python/requirements-mlx.txt +0 -5
  75. package/embedders/setup-mlx.mjs +0 -110
  76. package/embeddings.mjs +0 -571
  77. package/engine/binary.mjs +0 -437
  78. package/engine/memory.mjs +0 -1003
  79. package/engine/sqlite.mjs +0 -1136
  80. package/enrichment.mjs +0 -521
  81. package/git-signals.mjs +0 -248
  82. package/indexer.mjs +0 -546
  83. package/init.mjs +0 -1809
  84. package/layout.mjs +0 -123
  85. package/mcp/centrality.mjs +0 -117
  86. package/mcp/format.mjs +0 -61
  87. package/mcp/resolver.mjs +0 -116
  88. package/mcp/symbolgraph.mjs +0 -137
  89. package/mcp/taint.mjs +0 -225
  90. package/mcp/tools.mjs +0 -1582
  91. package/mcp/topology.mjs +0 -676
  92. package/mcp-server.mjs +0 -197
  93. package/parse/extractor.mjs +0 -628
  94. package/parse/imports.mjs +0 -365
  95. package/parse/interprocedural.mjs +0 -134
  96. package/parse/languages.mjs +0 -253
  97. package/parse/metadata.mjs +0 -701
  98. package/parse/routes.mjs +0 -327
  99. package/parse/scip.mjs +0 -391
  100. package/parse/taint-patterns.mjs +0 -119
  101. package/prompts/CORE.md +0 -91
  102. package/prompts/DOMAIN_TEMPLATE.md +0 -46
  103. package/prompts/INTEGRATION.md +0 -123
  104. package/prompts/frameworks/ANDROID.md +0 -17
  105. package/prompts/frameworks/ASPNET_CORE.md +0 -19
  106. package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
  107. package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
  108. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
  109. package/prompts/frameworks/RAILS.md +0 -20
  110. package/prompts/frameworks/REACT.md +0 -20
  111. package/prompts/frameworks/SPRING_BOOT.md +0 -20
  112. package/prompts/languages/BASH.md +0 -22
  113. package/prompts/languages/C.md +0 -21
  114. package/prompts/languages/CSHARP.md +0 -24
  115. package/prompts/languages/CSS_SCSS.md +0 -22
  116. package/prompts/languages/GO.md +0 -24
  117. package/prompts/languages/JAVA.md +0 -23
  118. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
  119. package/prompts/languages/KOTLIN.md +0 -24
  120. package/prompts/languages/PHP.md +0 -23
  121. package/prompts/languages/PYTHON.md +0 -23
  122. package/prompts/languages/RUBY.md +0 -23
  123. package/prompts/languages/RUST.md +0 -21
  124. package/prompts/languages/SWIFT.md +0 -21
  125. package/seal.mjs +0 -400
  126. package/search-core.mjs +0 -777
  127. package/search-sparse.mjs +0 -183
  128. package/storage.mjs +0 -73
  129. package/watch-daemon.mjs +0 -289
@@ -0,0 +1,973 @@
1
+ /**
2
+ * The MCP tool surface: eight read-only tools, each answering one kind of question an agent has
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
+ * Descriptions say when to use the tool (and when grep/read is better);
5
+ * outputs are compact text with file:line locations and explicit totals/truncation notes.
6
+ */
7
+ import path from 'node:path';
8
+ import { analyzeQuery } from '../search/tokenize.mjs';
9
+ import { loc, clip, codeBlock, symHeader, confWord, matchingLines, plural } from './render.mjs';
10
+ import { textSearch, findFiles, identifierOf } from '../query/textsearch.mjs';
11
+ import { checkChanges } from '../query/check.mjs';
12
+ import { definitionsUsed, testsUsing } from '../query/read.mjs';
13
+ import { factsForRange, renderFacts } from '../query/facts.mjs';
14
+ import fs from 'node:fs';
15
+
16
+ const TYPE_KINDS = new Set(['class', 'interface', 'struct', 'enum', 'trait', 'type', 'object', 'module', 'impl']);
17
+ const VALUE_KINDS = new Set(['field', 'property', 'variable', 'constant']);
18
+ const CALLABLE_KINDS = new Set(['function', 'method']);
19
+ // examples, docs snippets and sample apps come after the library code they exercise
20
+ const EXAMPLE_PATH = /(^|\/)(docs?_src|docs?|examples?|samples?|demos?|tutorials?|benchmarks?|fixtures?|playground)\//i;
21
+ const pathRank = (p) => (EXAMPLE_PATH.test(p) ? 1 : 0);
22
+
23
+ export const TOOLS = [
24
+ {
25
+ name: 'search_code',
26
+ title: 'Search code',
27
+ description: 'Ranked functions, classes and types for a behaviour described in words ("where are JWT tokens validated") or identifiers, with location, signature and matching lines. Filters: `path`, `kind`. Grep is better for literal strings and config keys.',
28
+ inputSchema: {
29
+ type: 'object',
30
+ properties: {
31
+ query: { type: 'string', description: 'What you are looking for: a behaviour description or identifier(s).' },
32
+ path: { type: 'string', description: 'Only results under this path prefix, e.g. "src/auth/".' },
33
+ kind: { type: 'string', description: 'Only this symbol kind (function, method, class, interface, struct, enum, type, field…).' },
34
+ limit: { type: 'integer', minimum: 1, maximum: 30, default: 8, description: 'Max results (default 8).' },
35
+ },
36
+ required: ['query'],
37
+ },
38
+ },
39
+ {
40
+ name: 'search_text',
41
+ title: 'Search text',
42
+ description: 'Grep over every file (regex or literal) that also gives each code match its enclosing function and, for an identifier, what the match is: the definition, a reference bound to a symbol, an unbound one, or a comment/string.',
43
+ inputSchema: {
44
+ type: 'object',
45
+ properties: {
46
+ pattern: { type: 'string', description: 'JavaScript regular expression (or literal text with literal: true).' },
47
+ path: { type: 'string', description: 'Only files under this path prefix, e.g. "src/" or "config/app.yaml".' },
48
+ literal: { type: 'boolean', default: false, description: 'Treat the pattern as plain text.' },
49
+ ignore_case: { type: 'boolean', default: false },
50
+ limit: { type: 'integer', minimum: 1, maximum: 300, default: 60, description: 'Max matching lines shown (totals are always complete).' },
51
+ },
52
+ required: ['pattern'],
53
+ },
54
+ },
55
+ {
56
+ name: 'read_code',
57
+ title: 'Read code',
58
+ description: 'Read functions, methods or classes by name ("Parser.parse"), ranges ("src/app.py:120-180") or files, several per call, with where each name the code uses is defined (file:line, signature) and, for a symbol, its overrides, users and tests. Files over 300 lines come back as an outline unless full: true.',
59
+ inputSchema: {
60
+ type: 'object',
61
+ properties: {
62
+ targets: { type: 'array', items: { type: 'string' }, description: 'Symbols (name, Class.method, path:Name), ranges (path:START-END) or files; up to 12.' },
63
+ full: { type: 'boolean', default: false, description: 'Whole files even when long (up to 2000 lines).' },
64
+ max_lines: { type: 'integer', minimum: 5, maximum: 1000, default: 200, description: 'Truncate a long definition after this many lines (default 200).' },
65
+ },
66
+ required: ['targets'],
67
+ },
68
+ },
69
+ {
70
+ name: 'find_references',
71
+ title: 'Find references',
72
+ description: 'Every use of a symbol (calls, instantiations, types, inheritance, decorators, values) by file with the enclosing function and line. Bound through scopes, imports and receiver types, so same-name methods of other classes are kept apart; calls through a parent type are marked. The footer says whether the list is complete and whether unbound same-name calls are plausible.',
73
+ inputSchema: {
74
+ type: 'object',
75
+ properties: {
76
+ symbol: { type: 'string', description: 'Name, Class.member, path:Name or path:line.' },
77
+ kind: { type: 'string', enum: ['all', 'call', 'type', 'inherit', 'new', 'value', 'decorator'], default: 'all', description: 'Only this reference kind.' },
78
+ path: { type: 'string', description: 'Only references in files under this path, e.g. "packages/".' },
79
+ include_tests: { type: 'boolean', default: true },
80
+ limit: { type: 'integer', minimum: 1, maximum: 500, default: 80 },
81
+ },
82
+ required: ['symbol'],
83
+ },
84
+ },
85
+ {
86
+ name: 'call_graph',
87
+ title: 'Call graph',
88
+ description: 'Callers (transitively, up to `depth`) and/or callees of a function or method, as a tree with locations and confidence: request and data flow across files.',
89
+ inputSchema: {
90
+ type: 'object',
91
+ properties: {
92
+ symbol: { type: 'string', description: 'Name, Class.member, path:Name or path:line.' },
93
+ direction: { type: 'string', enum: ['callers', 'callees', 'both'], default: 'both' },
94
+ depth: { type: 'integer', minimum: 1, maximum: 4, default: 2 },
95
+ limit: { type: 'integer', minimum: 5, maximum: 200, default: 40, description: 'Max nodes per direction.' },
96
+ include_tests: { type: 'boolean', default: true, description: 'Include callers in test files.' },
97
+ },
98
+ required: ['symbol'],
99
+ },
100
+ },
101
+ {
102
+ name: 'change_impact',
103
+ title: 'Change impact',
104
+ description: 'What changing some symbols or files (or `diff: true`, your uncommitted changes) affects: call sites to update, overrides and subclasses, callers of callers, tests that exercise it, files that change together, and what the index cannot see.',
105
+ inputSchema: {
106
+ type: 'object',
107
+ properties: {
108
+ symbols: { type: 'array', items: { type: 'string' }, description: 'Symbols you are changing.' },
109
+ files: { type: 'array', items: { type: 'string' }, description: 'Files you are changing (all their symbols).' },
110
+ diff: { type: 'boolean', default: false, description: 'Use the uncommitted git diff as the change set.' },
111
+ depth: { type: 'integer', minimum: 1, maximum: 5, default: 3 },
112
+ },
113
+ },
114
+ },
115
+ {
116
+ name: 'check_changes',
117
+ title: 'Check changes',
118
+ description: 'Checks your uncommitted edits without building: syntax errors, calls whose argument count no longer fits, removed or renamed names still in use, callers in files you did not touch, subclasses that inherit changed methods, and the closest tests with the command to run them.',
119
+ inputSchema: {
120
+ type: 'object',
121
+ properties: {
122
+ files: { type: 'array', items: { type: 'string' }, description: 'Only check these changed files/directories (default: all changes).' },
123
+ base: { type: 'string', default: 'HEAD', description: 'Git revision to compare with.' },
124
+ },
125
+ },
126
+ },
127
+ {
128
+ name: 'outline',
129
+ title: 'Outline',
130
+ description: 'Definitions without bodies: for a file, signatures and line ranges; for a directory or the repository (empty path), the most central symbols within a token budget, optionally centred on `focus`.',
131
+ inputSchema: {
132
+ type: 'object',
133
+ properties: {
134
+ path: { type: 'string', description: 'File or directory; empty = whole repository.' },
135
+ focus: { type: 'string', description: 'Optional task/query to center the repository map on.' },
136
+ max_tokens: { type: 'integer', minimum: 200, maximum: 8000, default: 1500 },
137
+ },
138
+ },
139
+ },
140
+ ];
141
+
142
+ // `get_symbol` predates read_code: still callable by clients configured for it, no longer listed
143
+ export const LEGACY_TOOLS = [
144
+ {
145
+ name: 'get_symbol',
146
+ title: 'Get symbol',
147
+ description: 'Read one definition instead of a whole file: source code with line numbers, signature, doc comment, members (for classes/structs/interfaces) and a summary of what it calls and who calls it. Accepts a name ("handleRequest"), qualified name ("Router.handle"), "path/file.ts:Name" or "path/file.ts:42" (the symbol enclosing that line). When a name is ambiguous, shows the most central definition and lists the others. Use it after search_code, or directly when you know the name. Set include_code=false to get only the signature and relationships.',
148
+ inputSchema: {
149
+ type: 'object',
150
+ properties: {
151
+ symbol: { type: 'string', description: 'Name, Class.member, path:Name or path:line.' },
152
+ include_code: { type: 'boolean', default: true, description: 'Include the source (default true).' },
153
+ max_lines: { type: 'integer', minimum: 5, maximum: 1000, default: 200, description: 'Truncate long bodies after this many lines (default 200).' },
154
+ },
155
+ required: ['symbol'],
156
+ },
157
+ },
158
+ ];
159
+
160
+ // loaded up front even when the client defers MCP tools behind a tool search: the one tool agents
161
+ // called in real sessions (docs/AGENTIC-BENCHMARK.md, rt1–rt2); every loaded definition is re-read
162
+ // on every turn, so the others wait behind the client's tool search.
163
+ const ALWAYS_LOAD = new Set(['find_references']);
164
+ for (const t of [...TOOLS, ...LEGACY_TOOLS]) {
165
+ t.annotations = { title: t.title, readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
166
+ if (ALWAYS_LOAD.has(t.name)) t._meta = { 'anthropic/alwaysLoad': true };
167
+ }
168
+
169
+ 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.`;
170
+
171
+ // ── handlers ─────────────────────────────────────────────────────────────────────
172
+
173
+ const SELF_DESCRIBING = /^(export\s+|pub(\([^)]*\))?\s+|public\s+|private\s+|protected\s+|internal\s+|static\s+|async\s+|abstract\s+|final\s+|override\s+|default\s+)*(function|func|def|fn|class|interface|struct|enum|trait|type|impl|module|namespace|object|record|macro_rules!|val|var|let|const|@)/;
174
+
175
+ /** A signature line that says what the symbol is without repeating the kind ("function function f"). */
176
+ function describe(r, max = 140) {
177
+ const sig = r.sig || r.qname || r.name;
178
+ return SELF_DESCRIBING.test(sig) ? clip(sig, max) : `${r.kind} ${clip(sig, max)}`;
179
+ }
180
+
181
+ /** Matches for a target, with overloads of one method collapsed (they are one definition to an agent). */
182
+ function pickSymbol(intel, target) {
183
+ const { matches } = intel.findSymbols(target);
184
+ const seen = new Set();
185
+ return matches.filter(m => { const k = `${m.file_id}|${m.qname}|${m.kind}|${m.is_static}`; return !seen.has(k) && seen.add(k); });
186
+ }
187
+
188
+ /** "other definitions" note: each alternative as a ready-to-use qualified target with its use count. */
189
+ function otherDefinitions(intel, matches, max = 5) {
190
+ // `path:Name` is ambiguous when one file declares the name twice (static + instance, overloads
191
+ // of different classes with the same qualified name): point at the line instead
192
+ const key = (m) => `${m.path}:${m.qname}`;
193
+ const dup = new Set(matches.map(key).filter((k, i, a) => a.indexOf(k) !== i));
194
+ const alts = matches.slice(1, 1 + max).map(m => `${dup.has(key(m)) ? `${m.path}:${m.start_line}` : key(m)}${m.is_static ? ' (static' : ' ('}${m.is_static ? ', ' : ''}${plural(refSummary(intel, m.id).n, 'ref')})`);
195
+ return alts.join(', ') + (matches.length > 1 + max ? `, … ${matches.length - 1 - max} more` : '');
196
+ }
197
+ /** Which of several same-name definitions is shown: `Logger.error (static) in path:209`. */
198
+ 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}`;
199
+
200
+ function notFound(intel, target) {
201
+ const { results } = intel.search.search(String(target), { limit: 5 });
202
+ const sugg = results.map(r => intel.sym(r.id)).filter(Boolean).map(s => ` ${symHeader(s)}`);
203
+ return `No symbol named "${target}".` + (sugg.length ? `\nClosest matches:\n${sugg.join('\n')}` : `\nTry ${tn('search_code')} with a description of what it does.`);
204
+ }
205
+
206
+ /**
207
+ * Footer for same-name call sites whose receiver type is unknown: list the plausible ones (their
208
+ * file mentions the declaring type, or the receiver is named after it) and say why the rest can
209
+ * be ignored, so the agent does not have to grep hundreds of unrelated calls.
210
+ */
211
+ function unboundNote(u, s, max = 8) {
212
+ if (!u?.total) return [];
213
+ const others = u.total - u.plausible.length;
214
+ const what = `"${s.name}(…)" call${u.total === 1 ? '' : 's'}`;
215
+ const typeText = u.typeNames.length ? typeList(u.typeNames, true) : null;
216
+ if (!u.plausible.length) {
217
+ return [`Unbound: ${u.total} other ${what} with receivers of unknown type; ${typeText ? `none is in a file that mentions ${typeText}` : 'none is in a file that imports this one or sits next to it'}, so none is likely to be a call to it.`];
218
+ }
219
+ const one = u.plausible.length === 1;
220
+ const where = typeText ? `${one ? 'is in a file that mentions' : 'are in files that mention'} ${typeText}` : `${one ? 'is in a file that imports' : 'are in files that import'} this one or next to it`;
221
+ const out = [`Possibly missed: ${u.plausible.length} "${s.name}(…)" call${one ? '' : 's'} with ${one ? 'a receiver' : 'receivers'} of unknown type ${where} — check ${one ? 'it' : 'them'}:`];
222
+ for (const r of u.plausible.slice(0, max)) out.push(` ${r.path}:${r.line}${r.recv ? ` (${r.recv}.${s.name})` : ''}`);
223
+ if (u.plausible.length > max) out.push(` … ${u.plausible.length - max} more`);
224
+ if (others) out.push(`The other ${others} unbound "${s.name}(…)" call${others === 1 ? ' is in a file' : 's are in files'} that never mention${others === 1 ? 's' : ''} ${typeText ?? 'it'}.`);
225
+ return out;
226
+ }
227
+
228
+ function typeList(names, code = false, max = 3) {
229
+ const q = (n) => code ? `\`${n}\`` : n;
230
+ return names.slice(0, max).map(q).join('/') + (names.length > max ? ` (+${names.length - max} related types)` : '');
231
+ }
232
+
233
+ function refSummary(intel, id) {
234
+ const c = intel.store.get(`SELECT COUNT(*) AS n, COUNT(DISTINCT file_id) AS f FROM refs WHERE dst_id = ?`, id);
235
+ return c ?? { n: 0, f: 0 };
236
+ }
237
+
238
+ /**
239
+ * Hints inside tool output name the next tool to call; through the CLI they must name the
240
+ * equivalent command instead (`symbol <target>`, not `get_symbol(…)`).
241
+ */
242
+ 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' };
243
+ let surface = 'mcp';
244
+ const tn = (name) => (surface === 'cli' ? `\`${CLI_NAMES[name]}\`` : name);
245
+
246
+ /**
247
+ * Agents often pass absolute paths (`/work/repo/src`, `./src/`): path arguments are relative to
248
+ * the repository root everywhere else, so normalise them once here.
249
+ */
250
+ function relativePaths(intel, args) {
251
+ const root = String(intel.root ?? '').replace(/\/+$/, '');
252
+ const rel = (p) => {
253
+ if (typeof p !== 'string') return p;
254
+ let r = p.trim();
255
+ if (root && (r === root || r.startsWith(root + '/'))) r = r.slice(root.length + 1);
256
+ r = r.replace(/^\.\/+/, '');
257
+ return r === '.' ? '' : r;
258
+ };
259
+ const out = { ...args };
260
+ if ('path' in out) out.path = rel(out.path);
261
+ if (Array.isArray(out.files)) out.files = out.files.map(rel);
262
+ else if (typeof out.files === 'string') out.files = out.files.split(',').map(rel);
263
+ return out;
264
+ }
265
+
266
+ export async function callTool(intel, name, args = {}, { cli = false } = {}) {
267
+ surface = cli ? 'cli' : 'mcp';
268
+ try { return await dispatchTool(intel, name, relativePaths(intel, args ?? {})); } finally { surface = 'mcp'; }
269
+ }
270
+
271
+ async function dispatchTool(intel, name, args) {
272
+ switch (name) {
273
+ case 'search_code': return toolSearch(intel, args);
274
+ case 'search_text': return toolText(intel, args);
275
+ case 'read_code': return toolRead(intel, args);
276
+ case 'get_symbol': return toolSymbol(intel, args); // predates read_code; no longer listed
277
+ case 'find_references': return toolReferences(intel, args);
278
+ case 'call_graph': return toolCallGraph(intel, args);
279
+ case 'change_impact': return toolImpact(intel, args);
280
+ case 'outline': return toolOutline(intel, args);
281
+ case 'check_changes': return toolCheck(intel, args);
282
+ case 'find_files': return toolFiles(intel, args); // CLI helper (`files`): MCP hosts have their own file finder
283
+ default: throw Object.assign(new Error(`Unknown tool: ${name}`), { code: -32602 });
284
+ }
285
+ }
286
+
287
+ function toolFiles(intel, { pattern, path: p = null, limit = 100 }) {
288
+ const pat = String(pattern ?? '').trim();
289
+ if (!pat) return 'Provide part of a file path or name, or a glob (*.toml, tests/test_*.py, docs/**).';
290
+ const glob = /[*?[{]/.test(pat);
291
+ const all = findFiles(intel.root, pat, { path: p });
292
+ const under = p ? ` under ${p}` : '';
293
+ if (!all.length) return `No file path ${glob ? 'matches' : 'contains'} "${pat}"${under}. Text without wildcards is matched anywhere in the path, ignoring case; a glob without a slash is matched against file names. To search inside files use ${tn('search_text')}.`;
294
+ const tests = new Set(intel.store.all('SELECT path FROM files WHERE is_test = 1').map(f => f.path));
295
+ const n = Math.min(Math.max(1, limit), 1000);
296
+ const out = [`${plural(all.length, 'file')} ${glob ? `matching "${pat}"` : `with "${pat}" in the path`}${under}:`];
297
+ for (const f of all.slice(0, n)) out.push(f + (tests.has(f) ? ' (test)' : ''));
298
+ if (all.length > n) out.push(`… ${all.length - n} more (narrow the pattern, or pass a path or a higher limit)`);
299
+ return out.join('\n');
300
+ }
301
+
302
+ async function toolSearch(intel, { query, path: p = null, kind = null, limit = 8 }) {
303
+ if (!query || !String(query).trim()) return 'Provide a query: a behaviour description or an identifier.';
304
+ const { results, total } = intel.search.search(String(query), { limit: Math.min(Math.max(1, limit), 30), path: p || null, kinds: kind ? [kind] : null });
305
+ 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.`;
306
+ const syms = results.map(r => intel.sym(r.id)).filter(Boolean);
307
+ await intel.revalidate([...new Set(syms.map(s => s.path))]);
308
+ const a = analyzeQuery(query);
309
+ const terms = [...new Set([...a.terms, ...a.identifiers.map(x => x.toLowerCase())])];
310
+ const out = [`${plural(syms.length, 'result')} for "${clip(query, 80)}"${total > syms.length ? ` (of ${total} candidates)` : ''}:`];
311
+ syms.forEach((s, i) => {
312
+ out.push(`${i + 1}. ${s.qname} — ${s.kind}${s.is_test ? ', test' : ''} · ${loc(s)}`);
313
+ if (s.sig) out.push(` ${clip(s.sig, 180)}`);
314
+ if (s.doc) out.push(` ${clip(s.doc, 160)}`);
315
+ if (i < 5 && !TYPE_KINDS.has(s.kind)) {
316
+ const lines = intel.fileLines(s.path);
317
+ for (const ln of matchingLines(lines, s, terms, 2)) out.push(` ${ln}: ${clip(lines[ln - 1].trim(), 150)}`);
318
+ }
319
+ });
320
+ out.push(surface === 'cli' ? 'Next: `read <name | path:START-END>…` to read them (several at once); `refs` / `callgraph` for usages.' : 'Next: read_code with the names you want (several at once); find_references / call_graph for usages.');
321
+ return out.join('\n');
322
+ }
323
+
324
+ async function toolText(intel, { pattern, path: p = null, literal = false, ignore_case = false, limit = 60 }) {
325
+ if (!pattern) return 'Provide a pattern.';
326
+ const r = await textSearch(intel, { pattern: String(pattern), path: p, literal, ignoreCase: ignore_case, limit: Math.min(Math.max(1, limit), 300) });
327
+ if (r.error) return r.error;
328
+ const shownPat = literal ? `"${clip(pattern, 60)}"` : `/${clip(pattern, 60)}/${ignore_case ? 'i' : ''}`;
329
+ if (!r.total) return [`No matches for ${shownPat}${p ? ` under ${p}` : ''}.`, ...definitionRescue(intel, pattern, literal, r)].join('\n');
330
+ const out = [`${plural(r.total, 'match', 'matches')} in ${plural(r.files, 'file')} for ${shownPat}${p ? ` under ${p}` : ''}${r.truncated ? ` (showing ${r.shown.length}; code first, then tests, examples and other files)` : ''}:`];
331
+ let cur = null;
332
+ for (const m of r.shown) {
333
+ if (m.path !== cur) { cur = m.path; out.push(m.path + (m.isTest ? ' (test)' : '')); }
334
+ // compact: the enclosing definition is the context; tags only where they add something
335
+ const tag = m.what === 'def' ? 'def' : m.what === 'ref' ? `→ ${m.target}` : m.what === 'unbound' ? 'unbound' : m.what === 'comment' ? 'comment' : '';
336
+ const where = m.encl && m.what !== 'def' ? `(${m.encl})` : '';
337
+ const label = [tag, where].filter(Boolean).join(' ');
338
+ out.push(`${String(m.line).padStart(6)} ${label ? label + ' ' : ''}│ ${clip(m.text.trim(), 120)}`);
339
+ }
340
+ if (r.truncated) out.push(`… ${r.total - r.shown.length} more matching lines (narrow with path, or raise limit)`);
341
+ const sm = r.summary;
342
+ if (sm) {
343
+ const parts = [...sm.targets].sort((a, b) => b[1] - a[1]).slice(0, 6).map(([k, n]) => { const [q, fp] = k.split('\u0000'); return `${n} → ${q} (${fp})`; });
344
+ if (sm.targets.size > 6) parts.push(`${sm.targets.size - 6} more targets`);
345
+ if (sm.defs) parts.push(`${sm.defs} definition${sm.defs === 1 ? '' : 's'}`);
346
+ if (sm.unbound) parts.push(`${sm.unbound} unbound`);
347
+ if (sm.text) parts.push(`${sm.text} in comments/strings/other text`);
348
+ if (sm.nonCode) parts.push(`${sm.nonCode} in non-code files`);
349
+ out.push(`"${sm.ident}" across all ${r.total} matches: ${parts.join(', ')}.`);
350
+ }
351
+ out.push(...definitionRescue(intel, pattern, literal, r));
352
+ return out.join('\n');
353
+ }
354
+
355
+ /**
356
+ * A search for a name whose definition it did not match — wrong file or directory, a pattern that
357
+ * misses the definition line, a name defined in another module — says where the definition is,
358
+ * so the next call is a read instead of another search.
359
+ */
360
+ function definitionRescue(intel, pattern, literal, r) {
361
+ if (r.summary?.defs) return [];
362
+ const ident = identifierOf(String(pattern), literal);
363
+ if (!ident || ident.length < 3) return [];
364
+ const defs = intel.store.all(`SELECT s.id, s.qname, s.kind, s.start_line, s.sig, f.path FROM symbols s JOIN files f ON f.id = s.file_id
365
+ WHERE s.name = ? AND s.kind NOT IN ('variable', 'parameter') ORDER BY f.is_test, (SELECT COUNT(*) FROM refs x WHERE x.dst_id = s.id) DESC LIMIT 4`, ident);
366
+ if (!defs.length) return [];
367
+ const shown = defs.slice(0, 3).map(d => `${d.qname} ${d.path}:${d.start_line} ${clip(String(d.sig || d.kind).replace(/\s+/g, ' '), 100)}`);
368
+ return [`${r.total ? 'None of these matches is where' : 'Not found here, but'} "${ident}" is defined${defs.length > 3 ? ' (first 3 of several)' : ''}:`, ...shown.map(x => ` ${x}`)];
369
+ }
370
+
371
+ /**
372
+ * "Defined elsewhere" card for lines [from, to] of a file: one line per definition the code uses that
373
+ * lives outside those lines — qualified name, location, signature (and the first doc line for the top
374
+ * few) — so a read also answers where the names it contains are defined. Definitions already listed
375
+ * in this answer (`seen`) are not repeated.
376
+ */
377
+ function definitionsCard(intel, file, from, to, { seen = new Set(), skipAncestorsOf = null, max = 10, docs = 1 } = {}) {
378
+ const { rows, more } = definitionsUsed(intel, file, from, to, { max, skipIds: seen, skipAncestorsOf });
379
+ if (!rows.length) return [];
380
+ const out = ['Defined elsewhere (used above):'];
381
+ rows.forEach((s, i) => {
382
+ seen.add(s.id);
383
+ const sig = clip(String(s.sig || `${s.kind} ${s.name}${s.type ? `: ${s.type}` : ''}`).replace(/\s+/g, ' ').replace(/([([{])\s+/g, '$1').replace(/,?\s+([)\]}])/g, '$1').replace(/:\s+#.*$/, ':').replace(/\s+#.*$/, ''), 90);
384
+ const doc = i < docs && s.doc ? ` — ${clip(s.doc.split('\n').map(l => l.trim()).find(Boolean) ?? '', 80)}` : '';
385
+ out.push(` ${s.qname} ${s.path}:${s.start_line}${s.byName ? ' (by name)' : ''} ${sig}${doc}`);
386
+ });
387
+ // the rest in one line, by name: reading any of them by name finds it
388
+ if (more.length) out.push(` … ${more.length} more: ${more.slice(0, 10).map(s => s.qname).join(', ')}${more.length > 10 ? ', …' : ''}`);
389
+ return out;
390
+ }
391
+
392
+ /**
393
+ * "Resolved" block for lines [from, to]: the indirection the lines involve, as conclusions with their
394
+ * location — which override runs, which table entry handles a key, what reaches a method by a name
395
+ * built at run time, what a decorator is (src/query/facts.mjs). Nothing when there is none; facts
396
+ * already given in this answer (`seen`, by key) are not repeated.
397
+ */
398
+ function factsBlock(intel, file, from, to, { seen = new Set(), max = 6, skip = null } = {}) {
399
+ const facts = factsForRange(intel, file, from, to, { max, shown: seen }).filter(f => !skip || !skip.has(f.kind));
400
+ if (!facts.length) return [];
401
+ for (const f of facts) seen.add(f.key);
402
+ return ['Resolved (what runs, what reaches it):', ...renderFacts(facts).map(l => ` ${l}`)];
403
+ }
404
+
405
+ /** Card rows for a read of n lines: about one per 20 lines read, between 5 and 12. */
406
+ const cardRows = (n) => Math.min(12, Math.max(5, Math.round(n / 20)));
407
+
408
+ // whole-file reads return files up to this many lines; longer ones come back as their outline
409
+ const READ_WHOLE_MAX = 300;
410
+
411
+ async function toolRead(intel, { targets = [], target = null, full = false, max_lines = 200 }) {
412
+ let list = Array.isArray(targets) ? [...targets] : typeof targets === 'string' ? targets.split(/[\s,]+/) : [];
413
+ if (target) list.unshift(target);
414
+ list = list.map(t => String(t).trim()).filter(Boolean);
415
+ if (!list.length) return 'Provide one or more targets: a symbol (Class.method), a file, or file:START-END.';
416
+ const seen = new Set();
417
+ const sections = [];
418
+ for (const t of list.slice(0, 12)) sections.push(await readOne(intel, t, { full, maxLines: max_lines, seen }));
419
+ if (list.length > 12) sections.push(`(${list.length - 12} more targets not read: at most 12 per call)`);
420
+ return sections.join('\n\n');
421
+ }
422
+
423
+ function isFile(intel, rel) {
424
+ try { return fs.statSync(path.join(intel.root, rel)).isFile(); } catch { return false; }
425
+ }
426
+
427
+ async function readOne(intel, raw, { full, maxLines, seen }) {
428
+ const root = String(intel.root ?? '').replace(/\/+$/, '');
429
+ const t = raw.startsWith(root + '/') ? raw.slice(root.length + 1) : raw.replace(/^\.\/+/, '');
430
+ const m = /^(.+?):(\d+)-(\d+)$/.exec(t);
431
+ if (m && isFile(intel, m[1])) return readRange(intel, m[1], Number(m[2]), Number(m[3]), { maxLines, seen });
432
+ if (isFile(intel, t)) return readFile(intel, t, { full, seen });
433
+ // anything else names a definition: name, Class.member, path:Name, path:LINE
434
+ return toolSymbol(intel, { symbol: t, include_code: true, max_lines: maxLines, seen });
435
+ }
436
+
437
+ async function readFile(intel, rel, { full, seen }) {
438
+ const indexed = intel.store.get('SELECT id FROM files WHERE path = ?', rel);
439
+ if (indexed) await intel.revalidate([rel]);
440
+ const lines = intel.fileLines(rel);
441
+ if (!lines) return `Cannot read ${rel}.`;
442
+ const n = lines.length && lines[lines.length - 1] === '' ? lines.length - 1 : lines.length;
443
+ if (!full && indexed && n > READ_WHOLE_MAX) {
444
+ return fileOutline(intel, rel, 1500) + `\n(${n} lines, so this is the outline. Read the parts you need by range or name — ${surface === 'cli' ? `\`read ${rel}:START-END Name …\`` : 'read_code with several targets'} — or the whole file with ${surface === 'cli' ? '`--full`' : 'full: true'}.)`;
445
+ }
446
+ const cap = full ? 2000 : READ_WHOLE_MAX;
447
+ const out = [`${rel} — ${plural(n, 'line')}`, codeBlock(lines, 1, n, { maxLines: cap })];
448
+ if (n > cap) out.push(`(${n - cap} more lines: read ${rel}:${cap + 1}-${n})`);
449
+ if (indexed) out.push(...factsBlock(intel, rel, 1, Math.min(n, cap), { seen }), ...definitionsCard(intel, rel, 1, Math.min(n, cap), { seen, max: cardRows(Math.min(n, cap)) }));
450
+ return out.join('\n');
451
+ }
452
+
453
+ async function readRange(intel, rel, a, b, { maxLines, seen }) {
454
+ const indexed = intel.store.get('SELECT id FROM files WHERE path = ?', rel);
455
+ if (indexed) await intel.revalidate([rel]);
456
+ const lines = intel.fileLines(rel);
457
+ if (!lines) return `Cannot read ${rel}.`;
458
+ if (a > b) [a, b] = [b, a];
459
+ a = Math.max(1, a); b = Math.min(b, lines.length);
460
+ if (a > b) return `${rel} has ${plural(lines.length, 'line')}; nothing at ${a}-${b}.`;
461
+ const encl = intel.store.get(`SELECT s.qname, s.kind, s.start_line, s.end_line FROM symbols s JOIN files f ON f.id = s.file_id
462
+ WHERE f.path = ? AND s.start_line <= ? AND s.end_line >= ? ORDER BY (s.end_line - s.start_line) ASC LIMIT 1`, rel, a, b);
463
+ const cap = Math.max(maxLines, 400);
464
+ const out = [`${rel}:${a}-${b}${encl ? ` — in ${encl.qname} (${encl.kind}, ${encl.start_line}-${encl.end_line})` : ''}`, codeBlock(lines, a, b, { maxLines: cap })];
465
+ if (b - a + 1 > cap) out.push(`(${b - a + 1 - cap} more lines: read ${rel}:${a + cap}-${b})`);
466
+ if (indexed) out.push(...factsBlock(intel, rel, a, Math.min(b, a + cap - 1), { seen }), ...definitionsCard(intel, rel, a, Math.min(b, a + cap - 1), { seen, max: cardRows(Math.min(b - a + 1, cap)) }));
467
+ return out.join('\n');
468
+ }
469
+
470
+ async function toolSymbol(intel, { symbol, include_code = true, max_lines = 200, seen = new Set() }) {
471
+ const matches = pickSymbol(intel, symbol);
472
+ if (!matches.length) return notFound(intel, symbol);
473
+ const s = matches[0];
474
+ await intel.revalidate([s.path]);
475
+ const fresh = intel.sym(s.id) ?? s;
476
+ const out = [`${fresh.qname} — ${fresh.kind}${fresh.exported ? ', exported' : ''}${fresh.is_test ? ', test' : ''} · ${loc(fresh)}`];
477
+ // with the code shown, its signature and docstring are in it
478
+ const span = fresh.end_line - fresh.start_line + 1;
479
+ const codeCap = TYPE_KINDS.has(fresh.kind) && span > max_lines ? Math.min(max_lines, 40) : max_lines;
480
+ if (fresh.sig && !include_code) out.push(`signature: ${clip(fresh.sig, 300)}`);
481
+ if (fresh.doc && !include_code) out.push(`doc: ${clip(fresh.doc, 400)}`);
482
+ if (fresh.parent_id) { const p = intel.sym(fresh.parent_id); if (p) out.push(`member of: ${p.qname} (${p.kind}) ${loc(p)}`); }
483
+ const bases = fresh.bases ? JSON.parse(fresh.bases) : [];
484
+ if (bases.length) out.push(`extends/implements: ${bases.join(', ')}`);
485
+ if (TYPE_KINDS.has(fresh.kind)) {
486
+ const mem = [...intel.members(fresh.id), ...intel.ownedMembers(fresh)];
487
+ // listed when the class body is not shown whole (members defined elsewhere always)
488
+ const shownWhole = include_code && span <= codeCap;
489
+ if (shownWhole) mem.splice(0, mem.length, ...mem.filter(m => m.path && m.path !== fresh.path));
490
+ if (mem.length) {
491
+ out.push(`members (${mem.length}):`);
492
+ for (const m of mem.slice(0, 60)) out.push(` ${String(m.start_line).padStart(5)} ${m.kind.padEnd(9)} ${clip(m.sig || m.name, 150)}${m.path && m.path !== fresh.path ? ` (${m.path})` : ''}`);
493
+ if (mem.length > 60) out.push(` … ${mem.length - 60} more`);
494
+ }
495
+ const { direct, indirect } = subtypeTree(intel, fresh.id);
496
+ if (direct.length) out.push(`subtypes/implementations (${direct.length}${direct.length > 20 ? ', showing 20' : ''}): ${direct.slice(0, 20).map(x => `${x.qname} (${x.path}:${x.start_line})`).join(', ')}`);
497
+ if (indirect.length) out.push(`indirect, through one of those (${indirect.length}${indirect.length > 20 ? ', showing 20' : ''}): ${indirect.slice(0, 20).map(x => `${x.qname} via ${x.via} (${x.path}:${x.start_line})`).join(', ')}`);
498
+ }
499
+ if (fresh.kind === 'method' && !fresh.is_static) {
500
+ const fam = intel.methodFamily(fresh);
501
+ const order = (a, b) => (a.is_test - b.is_test) || pathRank(a.path) - pathRank(b.path) || a.path.localeCompare(b.path);
502
+ fam.up.sort(order); fam.down.sort(order);
503
+ if (fam.up.length) out.push(`overrides/implements: ${fam.up.slice(0, 6).map(m => `${m.qname} (${m.path}:${m.start_line})`).join(', ')}`);
504
+ 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 ? ', …' : ''}`);
505
+ const inh = intel.inheritance(fresh.id, { max: 60 }).inherit.filter(x => !x.is_test);
506
+ 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 ? ', …' : ''}`);
507
+ }
508
+ // what the body uses that is defined elsewhere, with signatures (read the listed targets rather
509
+ // than searching for them); the bare callee list only when there is no card to show
510
+ seen.add(fresh.id);
511
+ const card = definitionsCard(intel, fresh.path, fresh.start_line, fresh.end_line, { seen, skipAncestorsOf: fresh.id, max: cardRows(Math.min(fresh.end_line - fresh.start_line + 1, max_lines)) });
512
+ const callees = card.length ? [] : intel.callees(fresh.id).filter(c => c.dst_id != null);
513
+ if (callees.length && !TYPE_KINDS.has(fresh.kind)) {
514
+ const uniq = new Map();
515
+ for (const c of callees) if (!uniq.has(c.dst_id)) uniq.set(c.dst_id, c);
516
+ out.push(`calls (${uniq.size}): ${[...uniq.values()].slice(0, 12).map(c => `${c.dst_qname} (${c.dst_path}:${c.dst_line})`).join(', ')}${uniq.size > 12 ? ', …' : ''}`);
517
+ }
518
+ // same counting as find_references (overloads and the method family included)
519
+ const refs = intel.references(fresh.id, { minConf: 0.4 });
520
+ if (refs.total) {
521
+ const top = [...refs.groups.values()].flat().slice(0, 4);
522
+ out.push(`used by: ${plural(refs.total, 'reference')} in ${plural(refs.groups.size, 'file')} — e.g. ${top.map(t => `${t.src_qname ?? '(module)'} ${t.path}:${t.line}`).join(', ')}${refs.total > 4 ? ` … (${tn('find_references')} for all)` : ''}`);
523
+ } else out.push(`used by: no bound references${VALUE_KINDS.has(fresh.kind) ? ' (field/attribute uses through untyped receivers are not all indexed — grep the name before concluding it is unused)' : ' (may be an entry point, framework-invoked, or called dynamically)'}.`);
524
+ if (refs.unbound?.plausible.length) out.push(`also possibly used at: ${refs.unbound.plausible.slice(0, 3).map(r => `${r.path}:${r.line}`).join(', ')}${refs.unbound.plausible.length > 3 ? ', …' : ''} (unknown receiver type, file mentions ${typeList(refs.unbound.typeNames) || 'it'})`);
525
+ const tests = fresh.is_test ? [] : testsUsing(intel, fresh.id);
526
+ if (tests.length) out.push(`tested in: ${tests.slice(0, 3).map(t => `${t.qname} (${t.path}:${t.line})`).join(', ')}${tests.length > 3 ? ', …' : ''}`);
527
+ if (include_code) {
528
+ const lines = intel.fileLines(fresh.path);
529
+ const cap = codeCap;
530
+ out.push('');
531
+ out.push(codeBlock(lines, fresh.start_line, fresh.end_line, { maxLines: cap }));
532
+ if (span > cap) out.push(`(${span - cap} more lines — read ${fresh.path}:${fresh.start_line + cap}-${fresh.end_line} or ${tn('read_code')} on a member)`);
533
+ }
534
+ out.push(...factsBlock(intel, fresh.path, fresh.start_line, fresh.end_line, { seen, skip: new Set(['overrides']) }));
535
+ if (card.length) out.push(...card);
536
+ if (matches.length > 1) {
537
+ out.push('');
538
+ out.push(`${matches.length - 1} other definition${matches.length > 2 ? 's' : ''} named "${symbol}":`);
539
+ for (const m of matches.slice(1, 8)) out.push(` ${symHeader(m)}`);
540
+ }
541
+ return out.join('\n');
542
+ }
543
+
544
+ async function toolReferences(intel, { symbol, kind = 'all', include_tests = true, limit = 80, path: p = null }) {
545
+ const matches = pickSymbol(intel, symbol);
546
+ if (!matches.length) return notFound(intel, symbol);
547
+ const s = matches[0];
548
+ const all = intel.references(s.id, { kinds: kind && kind !== 'all' ? [kind] : null, includeTests: include_tests });
549
+ const inPath = pathFilter(p);
550
+ const groups = p ? new Map([...all.groups].filter(([f]) => inPath(f))) : all.groups;
551
+ const res = { ...all, groups, total: p ? [...groups.values()].reduce((n, rows) => n + rows.length, 0) : all.total };
552
+ const files = [...res.groups.keys()];
553
+ await intel.revalidate(files);
554
+ const counts = {};
555
+ for (const rows of res.groups.values()) for (const r of rows) counts[r.confidence] = (counts[r.confidence] ?? 0) + 1;
556
+ const cstr = Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', ');
557
+ const scope = p ? ` under ${p}${all.total !== res.total ? ` (of ${all.total})` : ''}` : '';
558
+ const out = [`References to ${s.qname} (${s.kind}) ${loc(s)} — ${res.total} in ${plural(files.length, 'file')}${scope}${cstr ? ` (${cstr})` : ''}`];
559
+ if (matches.length > 1) out.push(`note: "${symbol}" matches ${matches.length} definitions; showing ${shownAs(s, matches)}. Others (pass one as symbol): ${otherDefinitions(intel, matches)}.`);
560
+ let shown = 0;
561
+ for (const [file, rows] of res.groups) {
562
+ if (shown >= limit) break;
563
+ const lines = intel.fileLines(file);
564
+ out.push(file + (rows[0].is_test ? ' (test)' : ''));
565
+ for (const r of rows) {
566
+ if (shown >= limit) break;
567
+ // an `implements`/`extends` clause may sit lines below the declaration it belongs to
568
+ const decl = r.kind === 'inherit' && r.src_line && r.src_line !== r.line ? ` (${r.src_kind} at line ${r.src_line})` : '';
569
+ const src = r.src_qname ? `in ${r.src_qname}${decl}` : 'module level';
570
+ const via = r.via ? ` via ${r.via}` : '';
571
+ const conf = r.confidence === 'exact' ? '' : ` [${r.confidence}${r.ncand > 1 ? `, ${r.ncand} candidates` : ''}]`;
572
+ out.push(` ${String(r.line).padStart(5)} ${r.kind.padEnd(9)} ${src}${via}${conf} │ ${clip((lines?.[r.line - 1] ?? '').trim(), 120)}`);
573
+ shown++;
574
+ }
575
+ }
576
+ if (res.total > shown) out.push(`… ${res.total - shown} more (raise limit, or filter with kind / path / include_tests=false)`);
577
+ if (TYPE_KINDS.has(s.kind) && (!kind || kind === 'all' || kind === 'inherit')) out.push(...indirectNote(intel, s, { includeTests: include_tests, inPath, p }));
578
+ const allKinds = !res.total && kind && kind !== 'all' ? intel.references(s.id, { includeTests: include_tests }).total : 0;
579
+ if (allKinds) out.push(`No references of kind "${kind}"; ${plural(allKinds, 'reference')} of other kinds exist (use kind: "all").`);
580
+ else if (!res.total) out.push(VALUE_KINDS.has(s.kind)
581
+ ? `No bound references. Uses of fields/attributes through untyped receivers are not all indexed — grep "${s.name}" before concluding it is unused.`
582
+ : 'No bound references. It may be unused, an entry point, invoked by a framework/reflection, or only called through dynamic receivers.');
583
+ if (res.elsewhere?.length) out.push(`Other references named "${s.name}" resolve elsewhere: ${res.elsewhere.map(e => `${e.n} → ${e.qname} (${e.path})`).join(', ')}.`);
584
+ out.push(...unboundNote(res.unbound, s));
585
+ if (CALLABLE_KINDS.has(s.kind) && (!kind || kind === 'all' || kind === 'call') && all.total && !res.unbound?.total) {
586
+ 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.`);
587
+ }
588
+ out.push(...stringMentionNote(intel, s));
589
+ return out.join('\n');
590
+ }
591
+
592
+ /** `path` argument → predicate on repository-relative paths (a directory prefix or one file). */
593
+ function pathFilter(p) {
594
+ if (!p) return () => true;
595
+ const dir = p.replace(/\/+$/, '') + '/';
596
+ return (f) => f === p || f.startsWith(dir);
597
+ }
598
+
599
+ /** Types that extend or implement a type: direct, and indirect through one of those (named in `via`). */
600
+ function subtypeTree(intel, id, maxDepth = 8) {
601
+ const q = `SELECT DISTINCT src.id, src.qname, src.kind, src.start_line, f.path, f.is_test FROM refs r JOIN symbols src ON src.id = r.src_id JOIN files f ON f.id = src.file_id WHERE r.dst_id = ? AND r.kind = 'inherit' ORDER BY f.is_test, f.path, src.start_line`;
602
+ const direct = intel.store.all(q, id);
603
+ const seen = new Set([id, ...direct.map(x => x.id)]);
604
+ const indirect = [];
605
+ let frontier = direct;
606
+ for (let depth = 2; depth <= maxDepth && frontier.length; depth++) {
607
+ const next = [];
608
+ for (const parent of frontier) {
609
+ for (const x of intel.store.all(q, parent.id)) {
610
+ if (seen.has(x.id)) continue;
611
+ seen.add(x.id);
612
+ next.push(x);
613
+ indirect.push({ ...x, via: parent.qname });
614
+ }
615
+ }
616
+ frontier = next;
617
+ }
618
+ indirect.sort((a, b) => (a.is_test - b.is_test) || a.path.localeCompare(b.path) || a.start_line - b.start_line);
619
+ return { direct, indirect };
620
+ }
621
+
622
+ /** For a type: what inherits it through its direct subtypes, so "every implementation" is one answer. */
623
+ function indirectNote(intel, s, { includeTests, inPath, p, max = 30 }) {
624
+ const { direct, indirect } = subtypeTree(intel, s.id);
625
+ if (!direct.length) return [];
626
+ const shown = indirect.filter(x => (includeTests || !x.is_test) && inPath(x.path));
627
+ if (!shown.length) {
628
+ const where = [p && `under ${p}`, !includeTests && 'outside tests'].filter(Boolean).join(' ');
629
+ return [indirect.length
630
+ ? `No indirect subtypes ${where} (${indirect.length} elsewhere).`
631
+ : `No indirect subtypes: nothing extends or implements the types that inherit ${s.name} directly.`];
632
+ }
633
+ return [`Indirect subtypes (${shown.length}) — inherit ${s.name} through the type after "via":`,
634
+ ...shown.slice(0, max).map(x => ` ${x.path}:${x.start_line} ${x.kind} ${x.qname} via ${x.via}${x.is_test ? ' (test)' : ''}`),
635
+ ...(shown.length > max ? [` … ${shown.length - max} more`] : [])];
636
+ }
637
+
638
+ /** Stubs/spies/getattr naming a member by string: a rename must update them, the index cannot bind them. */
639
+ function stringMentionNote(intel, s, max = 8) {
640
+ const sm = intel.stringMentions(s.id);
641
+ if (!sm.length) return [];
642
+ const nf = new Set(sm.map(m => m.path)).size;
643
+ return [`Also named as a string in ${plural(nf, 'file')} that use${nf === 1 ? 's' : ''} this class (stubs, spies, getattr — not bound by the index; a rename must update them):`,
644
+ ...sm.slice(0, max).map(m => ` ${m.path}:${m.line} │ ${clip(m.text, 110)}`),
645
+ ...(sm.length > max ? [` … ${sm.length - max} more`] : [])];
646
+ }
647
+
648
+ async function toolCallGraph(intel, { symbol, direction = 'both', depth = 2, limit = 40, include_tests = true }) {
649
+ const matches = pickSymbol(intel, symbol);
650
+ if (!matches.length) return notFound(intel, symbol);
651
+ const s = matches[0];
652
+ const out = [`Call graph of ${s.qname} (${s.kind}) ${loc(s)}`];
653
+ if (direction === 'callers' || direction === 'both') {
654
+ const kinds = ['call', 'new', 'value', 'decorator'];
655
+ const isTestFile = (p) => Boolean(intel.store.get('SELECT is_test FROM files WHERE path = ?', p)?.is_test);
656
+ // every direct caller of every node shown, so a function called from two places appears under
657
+ // both; one reached again is marked instead of expanded twice
658
+ const direct = (id) => {
659
+ const { nodes, fileLevel } = intel.dependents([id], { depth: 1, maxNodes: 400, kinds });
660
+ const callers = [...nodes].map(([k, info]) => ({ id: k, ...info, s: intel.sym(k) }))
661
+ .filter(k => k.s && (include_tests || !k.s.is_test))
662
+ .sort((a, b) => (a.s.is_test - b.s.is_test) || pathRank(a.s.path) - pathRank(b.s.path) || (b.conf - a.conf) || a.s.path.localeCompare(b.s.path) || a.s.start_line - b.s.start_line);
663
+ const modules = [...fileLevel].filter(([f]) => include_tests || !isTestFile(f));
664
+ return { callers, modules };
665
+ };
666
+ // levels first (breadth-first), so a function reachable at two depths is expanded at the
667
+ // shallower one; then print every edge, a node's callers under its first appearance at its level
668
+ const level = new Map([[s.id, 0]]), kids = new Map();
669
+ let frontier = [s.id];
670
+ for (let d = 1; d <= depth && frontier.length; d++) {
671
+ const next = [];
672
+ for (const id of frontier) {
673
+ const got = direct(id);
674
+ kids.set(id, got);
675
+ for (const k of got.callers) if (!level.has(k.id)) { level.set(k.id, d); next.push(k.id); }
676
+ }
677
+ frontier = next;
678
+ }
679
+ const lines = [];
680
+ const expanded = new Set([s.id]);
681
+ let count = 0, cut = false;
682
+ const walk = (id, indent, d) => {
683
+ const { callers, modules } = kids.get(id) ?? { callers: [], modules: [] };
684
+ for (const k of callers) {
685
+ if (count >= limit) { cut = true; return; }
686
+ const ks = k.s, here = level.get(k.id) === d && !expanded.has(k.id);
687
+ count++;
688
+ lines.push(`${' '.repeat(indent)}← ${ks.qname} ${loc(ks)}${ks.is_test ? ' (test)' : ''}${k.kind === 'override' ? ' (override)' : ''}${k.conf < 0.9 ? ` [${confWord(k.conf)}]` : ''}${here ? '' : ' (also listed elsewhere)'}`);
689
+ if (here) { expanded.add(k.id); if (d < depth) walk(k.id, indent + 1, d + 1); }
690
+ }
691
+ // module-level code (scripts, describe blocks) calling this node, placed under it
692
+ for (const [file, m] of modules) {
693
+ if (count >= limit) { cut = true; return; }
694
+ count++;
695
+ lines.push(`${' '.repeat(indent)}← module level of ${file}:${m.sites[0]}${isTestFile(file) ? ' (test)' : ''}${m.conf < 0.9 ? ` [${confWord(m.conf)}]` : ''}`);
696
+ }
697
+ };
698
+ // a recursive function is one of its own direct callers
699
+ const recursive = Boolean(intel.store.get(`SELECT 1 AS x FROM refs WHERE src_id = ? AND dst_id = ? AND kind = 'call' LIMIT 1`, s.id, s.id));
700
+ if (recursive) { lines.push(` ← ${s.qname} ${loc(s)} (itself: recursive)`); count++; }
701
+ walk(s.id, 1, 1);
702
+ const distinct = level.size - 1 + (recursive ? 1 : 0) + new Set([...kids.values()].flatMap(g => g.modules.map(([f]) => f))).size;
703
+ out.push(`callers (${distinct} distinct${cut ? ' shown' : ''}, depth ≤ ${depth}${include_tests ? '' : ', tests left out'}; indentation = one call level):`);
704
+ out.push(...lines);
705
+ if (!lines.length) out.push(' (no bound callers — entry point, framework-invoked, or dynamic dispatch)');
706
+ if (cut) out.push(` … more callers not shown (raise limit or lower depth)`);
707
+ const unbound = unboundNote(intel.unboundFor(s.id), s, 5);
708
+ if (unbound.length) out.push(...unbound.map(l => ' ' + l));
709
+ 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}.`);
710
+ }
711
+ if (direction === 'callees' || direction === 'both') {
712
+ out.push('callees:');
713
+ let count = 0;
714
+ const seen = new Set([s.id]);
715
+ const walk = (id, indent, d) => {
716
+ const uniq = new Map();
717
+ for (const c of intel.callees(id)) if (c.dst_id != null && !uniq.has(c.dst_id)) uniq.set(c.dst_id, c);
718
+ for (const c of uniq.values()) {
719
+ if (count >= limit) return;
720
+ count++;
721
+ const again = seen.has(c.dst_id);
722
+ seen.add(c.dst_id);
723
+ out.push(`${' '.repeat(indent)}→ ${c.dst_qname} ${c.dst_path}:${c.dst_line}${c.conf < 0.9 ? ` [${confWord(c.conf)}]` : ''}${again ? ' (seen)' : ''}`);
724
+ if (!again && d < depth) walk(c.dst_id, indent + 1, d + 1);
725
+ }
726
+ };
727
+ walk(s.id, 1, 1);
728
+ if (count === 0) out.push(' (calls nothing inside this repository)');
729
+ const external = intel.callees(s.id).filter(c => c.dst_id == null).map(c => (c.recv ? `${c.recv}.` : '') + c.name);
730
+ if (external.length) out.push(` external/unbound: ${[...new Set(external)].slice(0, 12).join(', ')}${external.length > 12 ? ', …' : ''}`);
731
+ }
732
+ if (matches.length > 1) out.push(`note: "${symbol}" matches ${matches.length} definitions; showing ${shownAs(s, matches)}. Others (pass one as symbol): ${otherDefinitions(intel, matches)}.`);
733
+ return out.join('\n');
734
+ }
735
+
736
+ async function toolImpact(intel, { symbols = [], files = [], diff = false, depth = 3 }) {
737
+ const seeds = new Set();
738
+ const labels = [];
739
+ const seedFiles = new Set(files);
740
+ if (diff) {
741
+ const d = intel.diffSeeds();
742
+ if (d.error) return `Could not read the git diff: ${d.error}`;
743
+ for (const id of d.seeds) seeds.add(id);
744
+ for (const f of d.files) seedFiles.add(f);
745
+ labels.push(`uncommitted diff (${plural(d.files.length, 'file')}, ${plural(d.seeds.length, 'changed symbol')})`);
746
+ }
747
+ for (const t of symbols) {
748
+ const m = pickSymbol(intel, t);
749
+ if (!m.length) { labels.push(`"${t}" (not found)`); continue; }
750
+ seeds.add(m[0].id);
751
+ seedFiles.add(m[0].path);
752
+ labels.push(`${m[0].qname}`);
753
+ }
754
+ for (const f of files) {
755
+ const rows = intel.store.all(`SELECT s.id FROM symbols s JOIN files fl ON fl.id = s.file_id WHERE fl.path = ? AND (s.parent_id IS NULL OR s.kind IN ('method','function'))`, f.replace(/^\.\//, ''));
756
+ for (const r of rows) seeds.add(r.id);
757
+ labels.push(f);
758
+ }
759
+ if (!seeds.size) return 'Nothing to analyse: pass symbols, files, or diff:true (with uncommitted changes).';
760
+ const { nodes, fileLevel, truncated, overflow } = intel.dependents([...seeds], { depth, maxNodes: 400 });
761
+ const direct = [], overrides = [], transitive = new Map();
762
+ const tests = new Map();
763
+ const affectedFiles = new Set();
764
+ const addTest = (file, what) => { const a = tests.get(file) ?? tests.set(file, []).get(file); if (!a.includes(what)) a.push(what); };
765
+ for (const [id, info] of nodes) {
766
+ const s = intel.sym(id);
767
+ if (!s) continue;
768
+ affectedFiles.add(s.path);
769
+ if (s.is_test) { addTest(s.path, s.name); continue; }
770
+ if (info.kind === 'override') overrides.push({ s, info });
771
+ else if (info.depth === 1) direct.push({ s, info });
772
+ else (transitive.get(info.depth) ?? transitive.set(info.depth, []).get(info.depth)).push({ s, info });
773
+ }
774
+ const moduleUses = [];
775
+ for (const [file, info] of fileLevel) {
776
+ const isTest = intel.store.get('SELECT is_test FROM files WHERE path = ?', file)?.is_test;
777
+ if (isTest) addTest(file, '(test code at module level / describe blocks)');
778
+ else moduleUses.push([file, info]);
779
+ }
780
+ moduleUses.sort((a, b) => pathRank(a[0]) - pathRank(b[0]) || a[0].localeCompare(b[0]));
781
+ overrides.sort((a, b) => pathRank(a.s.path) - pathRank(b.s.path) || a.s.path.localeCompare(b.s.path));
782
+ // subclasses that inherit a changed member without redeclaring it run the changed code too
783
+ const inheritors = new Map();
784
+ for (const id of seeds) for (const x of intel.inheritance(id).inherit) inheritors.set(x.id, x);
785
+ for (const t of intel.conventionTests([...seedFiles])) if (!tests.has(t)) tests.set(t, ['(by naming convention)']);
786
+ 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)']);
787
+ for (const x of inheritors.values()) if (x.is_test) { addTest(x.path, x.name); inheritors.delete(x.id); }
788
+
789
+ const out = [`Impact of changing ${labels.join(', ')}`];
790
+ const seedSyms = [...seeds].map(id => intel.sym(id)).filter(Boolean);
791
+ const exported = seedSyms.filter(s => s.exported && !s.is_test);
792
+ if (exported.length) out.push(`public surface: ${exported.slice(0, 8).map(s => s.qname).join(', ')}${exported.length > 8 ? ', …' : ''} ${exported.length === 1 ? 'is' : 'are'} exported`);
793
+
794
+ // 1. what has to change with a signature change: direct call sites and overrides
795
+ const siteCount = direct.reduce((n, x) => n + Math.max(1, x.info.sites?.length ?? 0), 0);
796
+ const examples = direct.filter(x => pathRank(x.s.path)).length;
797
+ out.push(`direct uses (update these if the signature or contract changes): ${plural(siteCount, 'site')} in ${plural(direct.length, 'function')}${examples ? ` (${examples} in examples/docs)` : ''}${truncated ? `; ${overflow} more dependents beyond the analysis limit (examples/docs first to be cut)` : ''}`);
798
+ direct.sort((a, b) => pathRank(a.s.path) - pathRank(b.s.path) || b.info.conf - a.info.conf || a.s.path.localeCompare(b.s.path));
799
+ let shown = 0;
800
+ for (const { s, info } of direct) {
801
+ if (shown >= 30) break;
802
+ const sites = info.sites?.length ? info.sites : [{ path: s.path, line: s.start_line }];
803
+ for (const site of sites.slice(0, 3)) {
804
+ if (shown >= 30) break;
805
+ out.push(` ${site.path}:${site.line} in ${s.qname}${info.conf < 0.9 ? ` [${confWord(info.conf)}]` : ''}`);
806
+ shown++;
807
+ }
808
+ }
809
+ if (siteCount > shown) out.push(` … ${siteCount - shown} more (${tn('find_references')} on the symbol lists them all)`);
810
+ for (const [file, info] of moduleUses.slice(0, 8)) out.push(` ${file}:${info.sites[0]} (module level)${info.conf < 0.9 ? ` [${confWord(info.conf)}]` : ''}`);
811
+ if (moduleUses.length > 8) out.push(` … ${moduleUses.length - 8} more files with module-level uses`);
812
+ if (overrides.length) {
813
+ out.push(`overrides / implementations (keep them in line with the change): ${overrides.length}`);
814
+ for (const { s } of overrides.slice(0, 15)) out.push(` ${s.qname} ${loc(s)}`);
815
+ if (overrides.length > 15) out.push(` … ${overrides.length - 15} more`);
816
+ }
817
+ if (inheritors.size) {
818
+ out.push(`inherited by (these subclasses run the changed code too): ${inheritors.size}`);
819
+ for (const x of [...inheritors.values()].slice(0, 15)) out.push(` ${x.qname} ${loc(x)}`);
820
+ if (inheritors.size > 15) out.push(` … ${inheritors.size - 15} more`);
821
+ }
822
+ // 2. what is affected further away
823
+ const transTotal = [...transitive.values()].reduce((n, a) => n + a.length, 0);
824
+ if (transTotal) {
825
+ out.push(`transitively affected (callers of callers): ${transTotal} symbols${truncated ? ' — truncated, narrow the change set for the full list' : ''}`);
826
+ for (const d of [...transitive.keys()].sort()) {
827
+ const arr = transitive.get(d).sort((a, b) => b.info.conf - a.info.conf);
828
+ out.push(` distance ${d} (${arr.length}):`);
829
+ for (const { s, info } of arr.slice(0, 12)) out.push(` ${s.qname} ${loc(s)}${info.conf < 0.9 ? ` [${confWord(info.conf)}]` : ''}`);
830
+ if (arr.length > 12) {
831
+ const byFile = new Map();
832
+ for (const { s } of arr.slice(12)) byFile.set(s.path, (byFile.get(s.path) ?? 0) + 1);
833
+ out.push(` … ${arr.length - 12} more in ${[...byFile].slice(0, 6).map(([f, n]) => `${f} (${n})`).join(', ')}${byFile.size > 6 ? ', …' : ''}`);
834
+ }
835
+ }
836
+ }
837
+ // 3. tests
838
+ if (tests.size) {
839
+ out.push(`tests to run (${tests.size} file${tests.size === 1 ? '' : 's'}):`);
840
+ for (const [f, names] of [...tests].slice(0, 15)) out.push(` ${f}: ${names.slice(0, 6).join(', ')}${names.length > 6 ? ', …' : ''}`);
841
+ if (tests.size > 15) out.push(` … ${tests.size - 15} more test files`);
842
+ } else out.push('tests: none found that reference the changed code (consider adding one).');
843
+ // 4. what the index cannot see
844
+ const blind = [];
845
+ for (const s of seedSyms.slice(0, 12)) {
846
+ if (s.kind === 'field' || s.kind === 'property') blind.push(`${s.qname}: field/attribute uses are only partly indexed (untyped receivers) — grep "${s.name}"`);
847
+ const u = intel.unboundFor(s.id);
848
+ if (u.plausible.length) blind.push(`${s.qname}: ${u.plausible.length} same-name call site${u.plausible.length === 1 ? '' : 's'} with unknown receiver type in files that mention ${u.typeNames.length ? u.typeNames.join('/') : 'it'} — ${u.plausible.slice(0, 4).map(r => `${r.path}:${r.line}`).join(', ')}${u.plausible.length > 4 ? ', …' : ''}`);
849
+ const sm = intel.stringMentions(s.id);
850
+ if (sm.length) blind.push(`${s.qname}: named as a string (stub/spy/getattr) at ${sm.slice(0, 4).map(m => `${m.path}:${m.line}`).join(', ')}${sm.length > 4 ? `, … ${sm.length - 4} more` : ''}`);
851
+ }
852
+ if (blind.length) { out.push('not visible to the index (check by hand):'); for (const b of blind) out.push(` ${b}`); }
853
+ const co = intel.coChange([...seedFiles]);
854
+ if (co.length) out.push(`often changed together (git): ${co.map(c => `${c.file} (${c.together}/${c.of})`).join(', ')}`);
855
+ const total = direct.length + overrides.length + inheritors.size + transTotal + moduleUses.length;
856
+ let risk = total > 40 || affectedFiles.size > 15 ? 'high' : total > 8 ? 'medium' : 'low';
857
+ if (!total && (blind.length || exported.length)) risk = `unknown (no bound dependents, but ${blind.length ? 'the index has blind spots listed above' : 'the code is exported and may be used by callers outside this repository or by a framework'})`;
858
+ out.push(`risk: ${risk} (${direct.length} direct, ${overrides.length} overrides, ${inheritors.size ? `${inheritors.size} inheriting subclasses, ` : ""}${transTotal} transitive, ${tests.size} test files)`);
859
+ return out.join('\n');
860
+ }
861
+
862
+ async function toolCheck(intel, { files = null, base = 'HEAD' }) {
863
+ const r = await checkChanges(intel, { base, files: files?.length ? files : null });
864
+ if (r.error) return r.error;
865
+ if (!r.files) return `No changed source files against ${base}${files?.length ? ` under ${files.join(', ')}` : ''}.`;
866
+ const out = [`Checked ${plural(r.files, 'changed file')} against ${base}.`];
867
+ let problems = 0;
868
+ if (r.syntax.length) {
869
+ problems += r.syntax.length;
870
+ out.push(`✗ syntax errors introduced (${r.syntax.length}):`);
871
+ for (const e of r.syntax.slice(0, 8)) out.push(` ${e.path}:${e.line}${e.missing ? ` (missing ${e.missing})` : ''} │ ${clip(e.text, 120)}`);
872
+ }
873
+ for (const a of r.arity) {
874
+ problems += a.sites.length;
875
+ out.push(a.newCall
876
+ ? `✗ call does not fit ${a.target} (${a.path}; takes ${a.is} argument${a.is === '1' ? '' : 's'}):`
877
+ : `✗ ${a.sites.length} of ${a.total} call site${a.total === 1 ? '' : 's'} no longer fit${a.sites.length === 1 ? 's' : ''} ${a.target} (${a.path}; now takes ${a.is}, was ${a.was}):`);
878
+ for (const x of a.sites.slice(0, 10)) out.push(` ${x.path}:${x.line}${x.src_qname ? ` in ${x.src_qname}` : ''} passes ${x.argc.n}${x.argc.open ? '+' : ''} │ ${clip(x.text ?? '', 110)}`);
879
+ if (a.sites.length > 10) out.push(` … ${a.sites.length - 10} more`);
880
+ }
881
+ for (const m of r.removed) {
882
+ problems += m.total + m.imports.length;
883
+ out.push(`✗ ${m.qname} (${m.kind}, ${m.path}) was removed or renamed but is still used (${m.total}${m.imports.length ? ` + ${plural(m.imports.length, 'import')}` : ''}):`);
884
+ for (const u of m.uses) out.push(` ${u.path}:${u.line}${u.src_qname ? ` in ${u.src_qname}` : ''}${u.recv ? ` (${u.recv}.${m.qname.split('.').pop()})` : ''}`);
885
+ if (m.total > m.uses.length) out.push(` … ${m.total - m.uses.length} more`);
886
+ for (const i of m.imports.slice(0, 6)) out.push(` imported at ${i.path}:${i.line}`);
887
+ }
888
+ for (const u of r.untouched) {
889
+ out.push(`• ${u.target} changed signature (${u.was} → ${u.is} arguments); ${plural(u.sites.length, 'call site')} in files you did not modify still fit — review them if the meaning changed:`);
890
+ for (const x of u.sites.slice(0, 8)) out.push(` ${x.path}:${x.line}${x.src_qname ? ` in ${x.src_qname}` : ''} │ ${clip(x.text ?? '', 110)}`);
891
+ if (u.sites.length > 8) out.push(` … ${u.sites.length - 8} more`);
892
+ }
893
+ for (const h of r.inherited ?? []) {
894
+ const members = h.members.join(', ');
895
+ 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` : ''}`);
896
+ 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` : ''}`);
897
+ }
898
+ if (r.cases.length) {
899
+ const name = (c) => `${c.path}::${c.cls ? `${c.cls}::` : ''}${c.name}`;
900
+ out.push(`test functions that reach the changed code through calls the index sees, or that you changed (${r.casesTotal}${r.casesTotal > r.cases.length ? `, first ${r.cases.length}` : ''}): ${r.cases.map(name).join(', ')}`);
901
+ for (const c of r.caseCommands) out.push(` run these first: ${clip(c, 600)}`);
902
+ }
903
+ if (r.tests.length) {
904
+ out.push(`test files that exercise the change (${r.tests.length}): ${r.tests.slice(0, 12).join(', ')}${r.tests.length > 12 ? ', …' : ''}`);
905
+ for (const c of r.commands) out.push(` run: ${clip(c, 400)}`);
906
+ } else out.push('tests: none found that exercise the changed code.');
907
+ out.push(problems ? `${plural(problems, 'problem')} found.` : 'No problems found in the changed code (this check does not replace running the tests).');
908
+ return out.join('\n');
909
+ }
910
+
911
+ /** Every definition of an indexed file with its signature and line range, within a token budget. */
912
+ function fileOutline(intel, rel, maxTokens = 1500) {
913
+ 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);
914
+ const f = intel.store.get('SELECT lines, lang, is_test FROM files WHERE path = ?', rel);
915
+ const depthOf = new Map();
916
+ const out = [`${rel} — ${f.lang}, ${f.lines} lines, ${plural(rows.length, 'symbol')}${f.is_test ? ', test file' : ''}`];
917
+ const imports = intel.store.all('SELECT DISTINCT source FROM imports i JOIN files f ON f.id = i.file_id WHERE f.path = ? LIMIT 30', rel).map(r => r.source);
918
+ if (imports.length) out.push(`imports: ${imports.join(', ')}`);
919
+ let budget = maxTokens * 4;
920
+ for (const r of rows) {
921
+ const d = r.parent_id != null ? (depthOf.get(r.parent_id) ?? 0) + 1 : 0;
922
+ depthOf.set(r.id, d);
923
+ if (r.kind === 'field' && d > 1) continue;
924
+ const line = `${String(r.start_line).padStart(5)}-${String(r.end_line).padEnd(5)} ${' '.repeat(d)}${describe(r)}`;
925
+ budget -= line.length;
926
+ if (budget < 0) { out.push(`… truncated (${rows.length} symbols); raise max_tokens`); break; }
927
+ out.push(line);
928
+ }
929
+ return out.join('\n');
930
+ }
931
+
932
+ async function toolOutline(intel, { path: p = '', focus = null, max_tokens = 1500 }) {
933
+ const rel = String(p || '').replace(/^\.\//, '').replace(/\/$/, '');
934
+ const isFile = rel && intel.store.get('SELECT id FROM files WHERE path = ?', rel);
935
+ if (isFile) {
936
+ await intel.revalidate([rel]);
937
+ return fileOutline(intel, rel, max_tokens);
938
+ }
939
+ // directory / repository map
940
+ let focusIds = null;
941
+ if (focus) {
942
+ const { results } = intel.search.search(focus, { limit: 10 });
943
+ focusIds = results.map(r => r.id);
944
+ }
945
+ const ranked = intel.rankedSymbols({ focusIds, pathPrefix: rel || null, limit: 600 });
946
+ if (!ranked.length) return rel ? `No indexed code under "${rel}".` : 'The index is empty.';
947
+ const st = intel.stats();
948
+ const header = rel ? `Map of ${rel}/ (most central symbols first${focus ? `, focused on "${clip(focus, 60)}"` : ''})` : `Repository map: ${st.files} files, ${st.symbols} symbols (${st.langs.slice(0, 5).map(l => `${l.lang} ${l.n}`).join(', ')}); most central symbols first${focus ? `, focused on "${clip(focus, 60)}"` : ''}`;
949
+ const byFile = new Map();
950
+ const fileOrder = [];
951
+ for (const r of ranked) {
952
+ if (!byFile.has(r.path)) { byFile.set(r.path, []); fileOrder.push(r.path); }
953
+ byFile.get(r.path).push(r);
954
+ }
955
+ const budgetChars = max_tokens * 4;
956
+ const render = (nFiles, perFile) => {
957
+ const out = [header];
958
+ for (const f of fileOrder.slice(0, nFiles)) {
959
+ out.push(f);
960
+ for (const r of byFile.get(f).slice(0, perFile).sort((a, b) => a.start_line - b.start_line)) out.push(` ${String(r.start_line).padStart(5)} ${describe(r, 120)}`);
961
+ }
962
+ return out.join('\n');
963
+ };
964
+ // binary search on the number of files that fits the budget
965
+ let lo = 1, hi = fileOrder.length, best = render(1, 6);
966
+ while (lo <= hi) {
967
+ const mid = (lo + hi) >> 1;
968
+ const text = render(mid, 6);
969
+ if (text.length <= budgetChars) { best = text; lo = mid + 1; } else hi = mid - 1;
970
+ }
971
+ const shownFiles = Math.min(fileOrder.length, hi < 1 ? 1 : hi);
972
+ return best + (fileOrder.length > shownFiles ? `\n… ${fileOrder.length - shownFiles} more files (raise max_tokens, narrow path, or add focus)` : '');
973
+ }