graph-indexer 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +456 -0
- package/README.md +72 -15
- package/SECURITY.md +21 -4
- package/bin/graph-indexer.mjs +46 -5
- package/integrations/claude-code/.claude-plugin/plugin.json +3 -2
- package/integrations/claude-code/agents/code-structure.md +0 -3
- package/integrations/claude-code/skills/setup/SKILL.md +26 -0
- package/integrations/claude-code-local/.claude-plugin/plugin.json +9 -0
- package/integrations/{claude-code → claude-code-local}/.mcp.json +1 -1
- package/integrations/claude-code-local/README.md +10 -0
- package/integrations/claude-code-local/agents/code-structure.md +22 -0
- package/integrations/{claude-code → claude-code-local}/hooks/gi-hook.mjs +12 -6
- package/integrations/opencode/graph-indexer.js +13 -8
- package/package.json +2 -2
- package/src/cli/helper.mjs +15 -1
- package/src/cli/hook.mjs +140 -48
- package/src/cli/init.mjs +49 -17
- package/src/cli/resident.mjs +15 -5
- package/src/index/discover.mjs +3 -5
- package/src/index/indexer.mjs +77 -41
- package/src/index/modules.mjs +82 -7
- package/src/index/parse-worker.mjs +12 -0
- package/src/index/parsepool.mjs +73 -0
- package/src/index/resolver.mjs +371 -25
- package/src/mcp/server.mjs +16 -12
- package/src/mcp/tools.mjs +419 -26
- package/src/parse/arity.mjs +41 -3
- package/src/parse/extract.mjs +277 -46
- package/src/parse/lang/ecma.mjs +2 -0
- package/src/parse/lang/go.mjs +2 -0
- package/src/parse/lang/jvm.mjs +33 -0
- package/src/parse/lang/markdown.mjs +92 -0
- package/src/parse/lang/python.mjs +43 -0
- package/src/parse/lang/rust.mjs +215 -15
- package/src/parse/lang/sfc.mjs +125 -0
- package/src/parse/languages.mjs +9 -2
- package/src/query/check.mjs +55 -17
- package/src/query/facts.mjs +3 -2
- package/src/query/injection-spring.mjs +405 -0
- package/src/query/injection.mjs +173 -0
- package/src/query/intel.mjs +87 -22
- package/src/query/issue.mjs +156 -0
- package/src/query/orm.mjs +286 -0
- package/src/query/read.mjs +6 -1
- package/src/query/report.mjs +53 -0
- package/src/query/routes.mjs +142 -0
- package/src/query/showcase.mjs +59 -0
- package/src/query/testcmd.mjs +25 -14
- package/src/query/textsearch.mjs +29 -11
- package/src/search/search.mjs +85 -17
- package/src/search/semantic.mjs +201 -0
- package/src/search/vectors.mjs +122 -0
- package/src/store/db.mjs +22 -4
- package/src/util/git.mjs +30 -0
- package/src/util/paths.mjs +40 -1
- package/src/util/regex.mjs +2 -0
- package/vendor/grammars/README.md +47 -0
- package/vendor/grammars/kotlin.wasm.gz +0 -0
- package/vendor/grammars/manifest.json +4 -4
- /package/integrations/{claude-code → claude-code-local}/hooks/hooks.json +0 -0
package/src/mcp/tools.mjs
CHANGED
|
@@ -1,26 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The MCP tool surface:
|
|
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';
|
|
22
|
+
import { withinRoot } from '../util/paths.mjs';
|
|
15
23
|
|
|
16
24
|
const TYPE_KINDS = new Set(['class', 'interface', 'struct', 'enum', 'trait', 'type', 'object', 'module', 'impl']);
|
|
17
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;
|
|
18
29
|
const CALLABLE_KINDS = new Set(['function', 'method']);
|
|
19
30
|
// examples, docs snippets and sample apps come after the library code they exercise
|
|
20
31
|
const EXAMPLE_PATH = /(^|\/)(docs?_src|docs?|examples?|samples?|demos?|tutorials?|benchmarks?|fixtures?|playground)\//i;
|
|
21
32
|
const pathRank = (p) => (EXAMPLE_PATH.test(p) ? 1 : 0);
|
|
22
33
|
|
|
23
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
|
+
},
|
|
24
50
|
{
|
|
25
51
|
name: 'search_code',
|
|
26
52
|
title: 'Search code',
|
|
@@ -29,11 +55,11 @@ export const TOOLS = [
|
|
|
29
55
|
type: 'object',
|
|
30
56
|
properties: {
|
|
31
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.' },
|
|
32
59
|
path: { type: 'string', description: 'Only results under this path prefix, e.g. "src/auth/".' },
|
|
33
60
|
kind: { type: 'string', description: 'Only this symbol kind (function, method, class, interface, struct, enum, type, field…).' },
|
|
34
61
|
limit: { type: 'integer', minimum: 1, maximum: 30, default: 8, description: 'Max results (default 8).' },
|
|
35
62
|
},
|
|
36
|
-
required: ['query'],
|
|
37
63
|
},
|
|
38
64
|
},
|
|
39
65
|
{
|
|
@@ -121,6 +147,7 @@ export const TOOLS = [
|
|
|
121
147
|
properties: {
|
|
122
148
|
files: { type: 'array', items: { type: 'string' }, description: 'Only check these changed files/directories (default: all changes).' },
|
|
123
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.' },
|
|
124
151
|
},
|
|
125
152
|
},
|
|
126
153
|
},
|
|
@@ -160,13 +187,14 @@ export const LEGACY_TOOLS = [
|
|
|
160
187
|
// loaded up front even when the client defers MCP tools behind a tool search: the one tool agents
|
|
161
188
|
// called in real sessions (docs/AGENTIC-BENCHMARK.md, rt1–rt2); every loaded definition is re-read
|
|
162
189
|
// on every turn, so the others wait behind the client's tool search.
|
|
163
|
-
|
|
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']);
|
|
164
192
|
for (const t of [...TOOLS, ...LEGACY_TOOLS]) {
|
|
165
193
|
t.annotations = { title: t.title, readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
166
194
|
if (ALWAYS_LOAD.has(t.name)) t._meta = { 'anthropic/alwaysLoad': true };
|
|
167
195
|
}
|
|
168
196
|
|
|
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.`;
|
|
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.`;
|
|
170
198
|
|
|
171
199
|
// ── handlers ─────────────────────────────────────────────────────────────────────
|
|
172
200
|
|
|
@@ -180,9 +208,25 @@ function describe(r, max = 140) {
|
|
|
180
208
|
|
|
181
209
|
/** Matches for a target, with overloads of one method collapsed (they are one definition to an agent). */
|
|
182
210
|
function pickSymbol(intel, target) {
|
|
183
|
-
const { matches } = intel.findSymbols(target);
|
|
211
|
+
const { matches, sameName } = intel.findSymbols(target);
|
|
184
212
|
const seen = new Set();
|
|
185
|
-
|
|
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`] : [])];
|
|
186
230
|
}
|
|
187
231
|
|
|
188
232
|
/** "other definitions" note: each alternative as a ready-to-use qualified target with its use count. */
|
|
@@ -197,7 +241,18 @@ function otherDefinitions(intel, matches, max = 5) {
|
|
|
197
241
|
/** Which of several same-name definitions is shown: `Logger.error (static) in path:209`. */
|
|
198
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}`;
|
|
199
243
|
|
|
200
|
-
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
|
+
}
|
|
201
256
|
const { results } = intel.search.search(String(target), { limit: 5 });
|
|
202
257
|
const sugg = results.map(r => intel.sym(r.id)).filter(Boolean).map(s => ` ${symHeader(s)}`);
|
|
203
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.`);
|
|
@@ -239,7 +294,7 @@ function refSummary(intel, id) {
|
|
|
239
294
|
* Hints inside tool output name the next tool to call; through the CLI they must name the
|
|
240
295
|
* equivalent command instead (`symbol <target>`, not `get_symbol(…)`).
|
|
241
296
|
*/
|
|
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' };
|
|
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' };
|
|
243
298
|
let surface = 'mcp';
|
|
244
299
|
const tn = (name) => (surface === 'cli' ? `\`${CLI_NAMES[name]}\`` : name);
|
|
245
300
|
|
|
@@ -270,6 +325,7 @@ export async function callTool(intel, name, args = {}, { cli = false } = {}) {
|
|
|
270
325
|
|
|
271
326
|
async function dispatchTool(intel, name, args) {
|
|
272
327
|
switch (name) {
|
|
328
|
+
case 'explore': return toolExplore(intel, args);
|
|
273
329
|
case 'search_code': return toolSearch(intel, args);
|
|
274
330
|
case 'search_text': return toolText(intel, args);
|
|
275
331
|
case 'read_code': return toolRead(intel, args);
|
|
@@ -280,6 +336,10 @@ async function dispatchTool(intel, name, args) {
|
|
|
280
336
|
case 'outline': return toolOutline(intel, args);
|
|
281
337
|
case 'check_changes': return toolCheck(intel, args);
|
|
282
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
|
|
283
343
|
default: throw Object.assign(new Error(`Unknown tool: ${name}`), { code: -32602 });
|
|
284
344
|
}
|
|
285
345
|
}
|
|
@@ -299,15 +359,21 @@ function toolFiles(intel, { pattern, path: p = null, limit = 100 }) {
|
|
|
299
359
|
return out.join('\n');
|
|
300
360
|
}
|
|
301
361
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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.`;
|
|
306
373
|
const syms = results.map(r => intel.sym(r.id)).filter(Boolean);
|
|
307
374
|
await intel.revalidate([...new Set(syms.map(s => s.path))]);
|
|
308
|
-
const a = analyzeQuery(
|
|
309
|
-
const
|
|
310
|
-
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)` : ''}:`];
|
|
311
377
|
syms.forEach((s, i) => {
|
|
312
378
|
out.push(`${i + 1}. ${s.qname} — ${s.kind}${s.is_test ? ', test' : ''} · ${loc(s)}`);
|
|
313
379
|
if (s.sig) out.push(` ${clip(s.sig, 180)}`);
|
|
@@ -421,7 +487,7 @@ async function toolRead(intel, { targets = [], target = null, full = false, max_
|
|
|
421
487
|
}
|
|
422
488
|
|
|
423
489
|
function isFile(intel, rel) {
|
|
424
|
-
try { return fs.statSync(path.join(intel.root, rel)).isFile(); } catch { return false; }
|
|
490
|
+
try { return withinRoot(intel.root, rel) && fs.statSync(path.join(intel.root, rel)).isFile(); } catch { return false; }
|
|
425
491
|
}
|
|
426
492
|
|
|
427
493
|
async function readOne(intel, raw, { full, maxLines, seen }) {
|
|
@@ -469,7 +535,7 @@ async function readRange(intel, rel, a, b, { maxLines, seen }) {
|
|
|
469
535
|
|
|
470
536
|
async function toolSymbol(intel, { symbol, include_code = true, max_lines = 200, seen = new Set() }) {
|
|
471
537
|
const matches = pickSymbol(intel, symbol);
|
|
472
|
-
if (!matches.length) return notFound(intel, symbol);
|
|
538
|
+
if (!matches.length) return notFound(intel, symbol, matches);
|
|
473
539
|
const s = matches[0];
|
|
474
540
|
await intel.revalidate([s.path]);
|
|
475
541
|
const fresh = intel.sym(s.id) ?? s;
|
|
@@ -502,8 +568,10 @@ async function toolSymbol(intel, { symbol, include_code = true, max_lines = 200,
|
|
|
502
568
|
fam.up.sort(order); fam.down.sort(order);
|
|
503
569
|
if (fam.up.length) out.push(`overrides/implements: ${fam.up.slice(0, 6).map(m => `${m.qname} (${m.path}:${m.start_line})`).join(', ')}`);
|
|
504
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 ? ', …' : ''}`);
|
|
505
|
-
const
|
|
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);
|
|
506
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 ? ', …' : ''}`);
|
|
507
575
|
}
|
|
508
576
|
// what the body uses that is defined elsewhere, with signatures (read the listed targets rather
|
|
509
577
|
// than searching for them); the bare callee list only when there is no card to show
|
|
@@ -543,7 +611,12 @@ async function toolSymbol(intel, { symbol, include_code = true, max_lines = 200,
|
|
|
543
611
|
|
|
544
612
|
async function toolReferences(intel, { symbol, kind = 'all', include_tests = true, limit = 80, path: p = null }) {
|
|
545
613
|
const matches = pickSymbol(intel, symbol);
|
|
546
|
-
|
|
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);
|
|
547
620
|
const s = matches[0];
|
|
548
621
|
const all = intel.references(s.id, { kinds: kind && kind !== 'all' ? [kind] : null, includeTests: include_tests });
|
|
549
622
|
const inPath = pathFilter(p);
|
|
@@ -582,13 +655,114 @@ async function toolReferences(intel, { symbol, kind = 'all', include_tests = tru
|
|
|
582
655
|
: 'No bound references. It may be unused, an entry point, invoked by a framework/reflection, or only called through dynamic receivers.');
|
|
583
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(', ')}.`);
|
|
584
657
|
out.push(...unboundNote(res.unbound, s));
|
|
658
|
+
const gaps = parseGaps(intel, s);
|
|
585
659
|
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.`);
|
|
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.`);
|
|
587
661
|
}
|
|
662
|
+
out.push(...parseGapNote(gaps, s));
|
|
588
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)`);
|
|
589
700
|
return out.join('\n');
|
|
590
701
|
}
|
|
591
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
|
+
|
|
592
766
|
/** `path` argument → predicate on repository-relative paths (a directory prefix or one file). */
|
|
593
767
|
function pathFilter(p) {
|
|
594
768
|
if (!p) return () => true;
|
|
@@ -647,7 +821,7 @@ function stringMentionNote(intel, s, max = 8) {
|
|
|
647
821
|
|
|
648
822
|
async function toolCallGraph(intel, { symbol, direction = 'both', depth = 2, limit = 40, include_tests = true }) {
|
|
649
823
|
const matches = pickSymbol(intel, symbol);
|
|
650
|
-
if (!matches.length) return notFound(intel, symbol);
|
|
824
|
+
if (!matches.length) return notFound(intel, symbol, matches);
|
|
651
825
|
const s = matches[0];
|
|
652
826
|
const out = [`Call graph of ${s.qname} (${s.kind}) ${loc(s)}`];
|
|
653
827
|
if (direction === 'callers' || direction === 'both') {
|
|
@@ -705,8 +879,10 @@ async function toolCallGraph(intel, { symbol, direction = 'both', depth = 2, lim
|
|
|
705
879
|
if (!lines.length) out.push(' (no bound callers — entry point, framework-invoked, or dynamic dispatch)');
|
|
706
880
|
if (cut) out.push(` … more callers not shown (raise limit or lower depth)`);
|
|
707
881
|
const unbound = unboundNote(intel.unboundFor(s.id), s, 5);
|
|
882
|
+
const gaps = parseGaps(intel, s);
|
|
708
883
|
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}.`);
|
|
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));
|
|
710
886
|
}
|
|
711
887
|
if (direction === 'callees' || direction === 'both') {
|
|
712
888
|
out.push('callees:');
|
|
@@ -781,7 +957,7 @@ async function toolImpact(intel, { symbols = [], files = [], diff = false, depth
|
|
|
781
957
|
overrides.sort((a, b) => pathRank(a.s.path) - pathRank(b.s.path) || a.s.path.localeCompare(b.s.path));
|
|
782
958
|
// subclasses that inherit a changed member without redeclaring it run the changed code too
|
|
783
959
|
const inheritors = new Map();
|
|
784
|
-
for (const id of seeds)
|
|
960
|
+
for (const id of seeds) { const h = intel.inheritance(id); for (const x of [...h.inherit, ...h.extend]) inheritors.set(x.id, x); }
|
|
785
961
|
for (const t of intel.conventionTests([...seedFiles])) if (!tests.has(t)) tests.set(t, ['(by naming convention)']);
|
|
786
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)']);
|
|
787
963
|
for (const x of inheritors.values()) if (x.is_test) { addTest(x.path, x.name); inheritors.delete(x.id); }
|
|
@@ -815,7 +991,7 @@ async function toolImpact(intel, { symbols = [], files = [], diff = false, depth
|
|
|
815
991
|
if (overrides.length > 15) out.push(` … ${overrides.length - 15} more`);
|
|
816
992
|
}
|
|
817
993
|
if (inheritors.size) {
|
|
818
|
-
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}`);
|
|
819
995
|
for (const x of [...inheritors.values()].slice(0, 15)) out.push(` ${x.qname} ${loc(x)}`);
|
|
820
996
|
if (inheritors.size > 15) out.push(` … ${inheritors.size - 15} more`);
|
|
821
997
|
}
|
|
@@ -859,8 +1035,8 @@ async function toolImpact(intel, { symbols = [], files = [], diff = false, depth
|
|
|
859
1035
|
return out.join('\n');
|
|
860
1036
|
}
|
|
861
1037
|
|
|
862
|
-
async function toolCheck(intel, { files = null, base = 'HEAD' }) {
|
|
863
|
-
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 });
|
|
864
1040
|
if (r.error) return r.error;
|
|
865
1041
|
if (!r.files) return `No changed source files against ${base}${files?.length ? ` under ${files.join(', ')}` : ''}.`;
|
|
866
1042
|
const out = [`Checked ${plural(r.files, 'changed file')} against ${base}.`];
|
|
@@ -893,6 +1069,7 @@ async function toolCheck(intel, { files = null, base = 'HEAD' }) {
|
|
|
893
1069
|
for (const h of r.inherited ?? []) {
|
|
894
1070
|
const members = h.members.join(', ');
|
|
895
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` : ''}`);
|
|
896
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` : ''}`);
|
|
897
1074
|
}
|
|
898
1075
|
if (r.cases.length) {
|
|
@@ -904,10 +1081,226 @@ async function toolCheck(intel, { files = null, base = 'HEAD' }) {
|
|
|
904
1081
|
out.push(`test files that exercise the change (${r.tests.length}): ${r.tests.slice(0, 12).join(', ')}${r.tests.length > 12 ? ', …' : ''}`);
|
|
905
1082
|
for (const c of r.commands) out.push(` run: ${clip(c, 400)}`);
|
|
906
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
|
+
}
|
|
907
1088
|
out.push(problems ? `${plural(problems, 'problem')} found.` : 'No problems found in the changed code (this check does not replace running the tests).');
|
|
908
1089
|
return out.join('\n');
|
|
909
1090
|
}
|
|
910
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
|
+
|
|
911
1304
|
/** Every definition of an indexed file with its signature and line range, within a token budget. */
|
|
912
1305
|
function fileOutline(intel, rel, maxTokens = 1500) {
|
|
913
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);
|