graph-indexer 2.0.0 → 2.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/mcp/tools.mjs CHANGED
@@ -8,17 +8,21 @@
8
8
  import { z } from 'zod';
9
9
  import fs from 'fs';
10
10
  import path, { resolve } from 'path';
11
- import { computePageRank, isNaturalLanguageQuery } from '../search-core.mjs';
11
+ import { computePageRank, isNaturalLanguageQuery, TEST_FILE_RE, learnedRerank, DEFAULT_RANKER_MODEL } from '../search-core.mjs';
12
12
  import { getParserForFile } from '../parse/languages.mjs';
13
13
  import { extractFileSkeleton } from '../parse/extractor.mjs';
14
14
  import { describeEmbedder } from '../embeddings.mjs';
15
- import { rerankResults, ollamaGenerate, mlxLmGenerate } from '../enrichment.mjs';
15
+ import { rerankResults, rerankCrossEncoder, crossEncoderScore, ollamaGenerate, mlxLmGenerate } from '../enrichment.mjs';
16
+ import { encodeMultiVector, rerankLateInteraction } from '../colbert.mjs';
16
17
  import { coChangesFor, gitBoostScore, computeFreshness, currentGitState } from '../git-signals.mjs';
18
+ import { egressGuardActive } from '../seal.mjs';
17
19
  import { extractSignatureLine, pruneBodyByQuery } from './format.mjs';
18
20
  import {
19
21
  assessConfidence, hydeQueryVector, buildHydePrompt, detectRepoLanguage,
20
- classifyCallers, findReferences, findRoutes, buildSubgraph,
22
+ classifyCallers, findReferences, findRoutes, buildSubgraph, buildImpact,
21
23
  } from './topology.mjs';
24
+ import { traceTaint, findTaintedSinks } from './taint.mjs';
25
+ import { CATEGORY_SEVERITY } from '../parse/taint-patterns.mjs';
22
26
 
23
27
  // ─── Structured (JSON) output helpers ────────────────────────────────────────────
24
28
 
@@ -116,7 +120,7 @@ function refCard({ chunk, recvHint, reason, confidence }) {
116
120
  * @param {object|null} [opts.gitSignals] Loaded git-signals sidecar (churn/recency/co-change), or null.
117
121
  * @param {number} [opts.gitRankBoost] 0..1 opt-in recency/churn weight in search_code (0 = ranking unchanged).
118
122
  */
119
- export function registerTools(server, db, { projectRoot, artifactPath, pidFile, embeddingsEnabled, embedder, rerank, hyde, ollamaHost = 'http://localhost:11434', llmProvider = 'ollama', mlxLmHost = 'http://localhost:8080', gitSignals = null, gitRankBoost = 0 }) {
123
+ export function registerTools(server, db, { projectRoot, artifactPath, pidFile, embeddingsEnabled, embedder, rerank, hyde, ollamaHost = 'http://localhost:11434', llmProvider = 'ollama', mlxLmHost = 'http://localhost:8080', gitSignals = null, gitRankBoost = 0, sealed = 'off', ranker = 'rrf' }) {
120
124
 
121
125
  // Scoped here (not module-level) so multiple servers in one process don't cross-contaminate.
122
126
  // `undefined` = not yet detected; `null` = unknown (→ generic prompt).
@@ -156,6 +160,25 @@ export function registerTools(server, db, { projectRoot, artifactPath, pidFile,
156
160
  return bits.join(' · ');
157
161
  };
158
162
 
163
+ // ── test → code mapping (C5) ──────────────────────────────────────────────
164
+ // The test chunks that exercise a symbol: a test/spec chunk (TEST_FILE_RE) that
165
+ // either CALLS the symbol or REFERENCES it (type/inheritance). Deduped by id,
166
+ // deterministically ordered (file, line, id). Shared by tests_for + explain_symbol.
167
+ const testsForSymbol = (symbol) => {
168
+ const seen = new Set();
169
+ const out = [];
170
+ for (const c of [...db.findCallers(symbol), ...db.findReferers(symbol)]) {
171
+ if (!c || !TEST_FILE_RE.test(c.file_path) || seen.has(c.id)) continue;
172
+ seen.add(c.id);
173
+ out.push(c);
174
+ }
175
+ out.sort((a, b) =>
176
+ (a.file_path < b.file_path ? -1 : a.file_path > b.file_path ? 1 : 0)
177
+ || (a.start_line - b.start_line)
178
+ || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
179
+ return out;
180
+ };
181
+
159
182
  // ─── search_code ────────────────────────────────────────────────────────────
160
183
  server.tool(
161
184
  'search_code',
@@ -234,26 +257,61 @@ export function registerTools(server, db, { projectRoot, artifactPath, pidFile,
234
257
  // in the tool layer — never in db.searchHybrid — so measured
235
258
  // retrieval ranking and backend parity are unchanged unless enabled.
236
259
  const applyGitBoost = gitRankBoost > 0 && gitSignals;
260
+ // D3: the learned re-rank also needs a deeper pool to RESCUE a structurally-strong
261
+ // but lexically-deep hit into top_k (same rationale as rerank / git boost).
262
+ const wantLearned = ranker === 'learned' && !exact_tokens;
237
263
  const poolSize = willRerank
238
264
  ? Math.min(Math.max(top_k, rerank?.poolSize ?? 15), 25)
239
- : applyGitBoost ? Math.min(Math.max(top_k * 2, 12), 25) : top_k;
265
+ : (applyGitBoost || wantLearned) ? Math.min(Math.max(top_k * 2, 12), 25) : top_k;
240
266
  let matches = db.searchHybrid(fullQuery, queryVector, poolSize, min_score, exact_tokens || null);
241
267
 
268
+ // D3: post-fusion learned re-rank (opt-in). Reorders the fused pool by a zero-dep
269
+ // linear model over RRF score + centrality + resolved/SCIP in-degree + git, BEFORE the
270
+ // optional LLM judge / git nudge. Accessors are null when the layer is absent, so the
271
+ // model degrades to RRF + git + node-type (≈neutral) on a plain index.
272
+ if (wantLearned && matches.length > 1) {
273
+ matches = learnedRerank(matches, {
274
+ getCentrality: db.hasCentrality?.() ? (id) => db.getCentrality(id) : null,
275
+ getInEdges: db.hasSymbolGraph?.() ? (id) => db.getEdges(id, { direction: 'in' }) : null,
276
+ gitScoreFor: gitSignals ? (file) => gitBoostScore(gitSignals, file) : null,
277
+ }, DEFAULT_RANKER_MODEL);
278
+ }
279
+
242
280
  let rerankFailed = false;
243
281
  if (willRerank && matches.length > 1) {
244
282
  try {
245
- matches = await rerankResults(fullQuery, matches, {
246
- topM: Math.min(rerank?.topM ?? 12, matches.length),
247
- generate: llmProvider === 'mlx'
248
- ? (prompt) => mlxLmGenerate(prompt, { mlxLmHost, timeoutMs: 60000 })
249
- : (prompt) => ollamaGenerate(prompt, {
250
- model: rerank?.model || 'qwen2.5-coder:7b',
251
- ollamaHost, timeoutMs: 60000,
252
- options: { temperature: 0, num_predict: 40 },
253
- }),
254
- });
283
+ if (rerank?.provider === 'cross-encoder') {
284
+ // Local air-gapped cross-encoder: scores (query, candidate) pairs and
285
+ // sorts. Reuses the same over-fetched pool; deterministic; no LLM call.
286
+ matches = await rerankCrossEncoder(fullQuery, matches, {
287
+ topM: Math.min(rerank?.topM ?? 12, matches.length),
288
+ scorer: (q, texts) => crossEncoderScore(q, texts, { model: rerank?.crossEncoderModel }),
289
+ });
290
+ } else if (rerank?.provider === 'late-interaction') {
291
+ // B4 ColBERT-style late interaction: encode the query into multi-vectors
292
+ // with the SAME local embedder, then MaxSim-rerank against each candidate's
293
+ // stored doc sub-vectors. Air-gapped, deterministic, parity-safe; a no-op
294
+ // (original order) without --embeddings (no embedder / no doc vectors).
295
+ const qVecs = embedder ? await encodeMultiVector(embedder, fullQuery) : [];
296
+ matches = rerankLateInteraction(matches, {
297
+ qVecs,
298
+ binPath: db.embeddingBinPath?.(),
299
+ topM: Math.min(rerank?.topM ?? 12, matches.length),
300
+ });
301
+ } else {
302
+ matches = await rerankResults(fullQuery, matches, {
303
+ topM: Math.min(rerank?.topM ?? 12, matches.length),
304
+ generate: llmProvider === 'mlx'
305
+ ? (prompt) => mlxLmGenerate(prompt, { mlxLmHost, timeoutMs: 60000 })
306
+ : (prompt) => ollamaGenerate(prompt, {
307
+ model: rerank?.model || 'qwen2.5-coder:7b',
308
+ ollamaHost, timeoutMs: 60000,
309
+ options: { temperature: 0, num_predict: 40 },
310
+ }),
311
+ });
312
+ }
255
313
  } catch {
256
- // An unreachable or slow judge must degrade gracefully — never
314
+ // An unreachable or slow reranker must degrade gracefully — never
257
315
  // turn the whole search into an error. `matches` retains its
258
316
  // pre-rerank order because the throwing await never reassigned it.
259
317
  rerankFailed = true;
@@ -774,10 +832,11 @@ export function registerTools(server, db, { projectRoot, artifactPath, pidFile,
774
832
  },
775
833
  async ({ symbol, target_class, response_format }) => {
776
834
  try {
777
- const { targetDefs, ambiguous, calls, inherits, types } =
835
+ const { targetDefs, ambiguous, calls, inherits, types, references } =
778
836
  findReferences(db, symbol, { targetClass: target_class || null });
779
837
  const callTotal = calls.high.length + calls.nameOnly.length;
780
838
  const total = callTotal + inherits.length + types.length;
839
+ const scipRefs = references || [];
781
840
  const coChanges = coChangeFiles(gitSignals, [...new Set(targetDefs.map(d => d.file_path))]);
782
841
  const fresh = indexFreshness();
783
842
  const note = freshnessNote(fresh);
@@ -795,12 +854,15 @@ export function registerTools(server, db, { projectRoot, artifactPath, pidFile,
795
854
  },
796
855
  subclassed_by: inherits.map(refCard),
797
856
  used_as_type_by: types.map(refCard),
857
+ // A2 v2: binding-precise cross-file references from a SCIP index (resolved tier).
858
+ referenced_by_resolved: scipRefs.map(refCard),
859
+ resolved_reference_count: scipRefs.length,
798
860
  co_changes: coChanges,
799
861
  index: fresh,
800
862
  });
801
863
  }
802
864
 
803
- if (total === 0) {
865
+ if (total === 0 && scipRefs.length === 0) {
804
866
  const parts = [`✅ No references to \`${symbol}\` found in the index.`];
805
867
  if (coChanges.length) parts.push(coChangeLine(coChanges));
806
868
  if (note) parts.push(note);
@@ -813,7 +875,8 @@ export function registerTools(server, db, { projectRoot, artifactPath, pidFile,
813
875
  + (recvHint ? ` · via ${recvHint}` : '')
814
876
  + (confidence === 'name-only' ? ' · ⚠️ unverified' : '');
815
877
 
816
- const lines = [`# 🔗 References to \`${symbol}\` — ${total} total`];
878
+ const lines = [`# 🔗 References to \`${symbol}\` — ${total} total`
879
+ + (scipRefs.length ? ` · ${scipRefs.length} SCIP-resolved` : '')];
817
880
  if (ambiguous) lines.push(`⚠️ ${targetDefs.length} symbols named \`${symbol}\` — name-only matches may target a different one.`);
818
881
 
819
882
  if (callTotal) {
@@ -829,6 +892,12 @@ export function registerTools(server, db, { projectRoot, artifactPath, pidFile,
829
892
  lines.push('', `## 🏷 Used as a type by (${types.length})`);
830
893
  for (const it of types) lines.push(fmt(it));
831
894
  }
895
+ if (scipRefs.length) {
896
+ lines.push('', `## 🎯 Referenced by — SCIP-resolved (${scipRefs.length})`,
897
+ '_Binding-precise cross-file references from your SCIP index (kind-agnostic): disambiguates'
898
+ + ' same-named symbols and catches uses the name heuristics above can miss._');
899
+ for (const it of scipRefs) lines.push(fmt(it));
900
+ }
832
901
  if (coChanges.length) lines.push('', coChangeLine(coChanges));
833
902
  if (note) lines.push('', note);
834
903
  return { content: [{ type: 'text', text: lines.join('\n') }] };
@@ -1000,6 +1069,11 @@ export function registerTools(server, db, { projectRoot, artifactPath, pidFile,
1000
1069
  const totalFiles = fileChunks.size;
1001
1070
  const totalSymbols = Array.from(fileChunks.values()).reduce((s, a) => s + a.length, 0);
1002
1071
 
1072
+ // A5: most-central symbols (PageRank over the resolved symbol graph), shown only
1073
+ // on the unfiltered orientation view and only when --symbol-graph built it.
1074
+ // Absent on the default path → output byte-identical.
1075
+ const central = (db.hasCentrality?.() && !path_filter) ? db.topCentral(12) : [];
1076
+
1003
1077
  if (response_format === 'json') {
1004
1078
  const files = sortedFiles.map(filePath => {
1005
1079
  const seen = new Set();
@@ -1019,6 +1093,14 @@ export function registerTools(server, db, { projectRoot, artifactPath, pidFile,
1019
1093
  return jsonResult({
1020
1094
  total_files: totalFiles, total_symbols: totalSymbols,
1021
1095
  shown_files: sortedFiles.length, sort_by, path_filter: path_filter || null,
1096
+ ...(central.length ? {
1097
+ central_total: db.getCentrality(central[0].chunk.id)?.total ?? central.length,
1098
+ central_symbols: central.map(({ chunk, score, rank }) => ({
1099
+ name: chunk.name, class_context: chunk.class_context || null,
1100
+ file_path: chunk.file_path, node_type: chunk.node_type,
1101
+ rank, score,
1102
+ })),
1103
+ } : {}),
1022
1104
  files,
1023
1105
  });
1024
1106
  }
@@ -1029,6 +1111,16 @@ export function registerTools(server, db, { projectRoot, artifactPath, pidFile,
1029
1111
  sortedFiles.length < totalFiles ? `(showing top ${sortedFiles.length} by ${sort_by}; use path_filter to narrow)\n` : '',
1030
1112
  ].filter(Boolean);
1031
1113
 
1114
+ if (central.length) {
1115
+ const cenTotal = db.getCentrality(central[0].chunk.id)?.total ?? central.length;
1116
+ lines.push(`**Most central symbols** (PageRank over the resolved symbol graph, top ${central.length} of ${cenTotal}):`);
1117
+ for (const { chunk, rank } of central) {
1118
+ const nm = `${chunk.class_context ? chunk.class_context + '.' : ''}${chunk.name}`;
1119
+ lines.push(` ${rank}. \`${nm}\` — \`${chunk.file_path}\``);
1120
+ }
1121
+ lines.push('');
1122
+ }
1123
+
1032
1124
  for (const filePath of sortedFiles) {
1033
1125
  const chunks = fileChunks.get(filePath);
1034
1126
  lines.push(`\n${filePath}`);
@@ -1116,6 +1208,10 @@ export function registerTools(server, db, { projectRoot, artifactPath, pidFile,
1116
1208
  embeddings_enabled: Boolean(embeddingsEnabled),
1117
1209
  embedder: embedder ? { provider: embedder.provider, model: embedder.model, dim: embedder.dim ?? null } : null,
1118
1210
  lazy_mode: s.lazyMode,
1211
+ sealed: sealed,
1212
+ egress_guard: sealed === 'off' ? 'off' : (egressGuardActive() ? 'active' : 'requested'),
1213
+ taint_flows: db.hasTaint?.() ? (db.getTaintFlows()?.flows?.length ?? 0) : null,
1214
+ scip_references: db.hasScipRefs?.() ? (db.scipRefCount?.() ?? null) : null,
1119
1215
  daemon_running: daemonRunning,
1120
1216
  index_age_seconds: ageSeconds,
1121
1217
  freshness: {
@@ -1139,6 +1235,9 @@ export function registerTools(server, db, { projectRoot, artifactPath, pidFile,
1139
1235
  `| **Vector entries** | ${s.vectors} |`,
1140
1236
  `| **Search mode** | ${searchMode} |`,
1141
1237
  `| **Lazy vec mode** | ${s.lazyMode ? '✅ Yes (enterprise scale)' : '❌ No (small corpus)'} |`,
1238
+ ...(sealed !== 'off' ? [`| **Sealed mode** | 🔒 ${sealed} · egress guard ${egressGuardActive() ? 'active' : 'requested'} |`] : []),
1239
+ ...(db.hasTaint?.() ? [`| **Taint flows** | 🧪 ${db.getTaintFlows()?.flows?.length ?? 0} precomputed |`] : []),
1240
+ ...(db.hasScipRefs?.() ? [`| **SCIP references** | 🎯 ${db.scipRefCount?.() ?? 0} definitions (precise cross-file) |`] : []),
1142
1241
  `| **Daemon** | ${daemonStatus} |`,
1143
1242
  `| **Index age** | ${indexAge} |`,
1144
1243
  `| **Built at commit** | ${fresh.indexedCommit || '—'}${fresh.commitMoved ? ` (HEAD now ${fresh.currentCommit})` : ''} |`,
@@ -1154,4 +1253,330 @@ export function registerTools(server, db, { projectRoot, artifactPath, pidFile,
1154
1253
  }
1155
1254
  }
1156
1255
  );
1256
+
1257
+ // ─── tests_for ────────────────────────────────────────────────────────────────
1258
+ server.tool(
1259
+ 'tests_for',
1260
+ 'Find the tests that exercise a symbol — the test/spec chunks that call or reference it. '
1261
+ + 'Use it to see how a function/class is meant to behave, or which tests to run (or update) '
1262
+ + 'before changing it. Returns each test chunk + id (call get_chunk for the body).',
1263
+ {
1264
+ symbol: z.string().describe("Exact symbol name (function, class, method) to find tests for."),
1265
+ response_format: z.enum(['markdown', 'json']).default('markdown').describe(
1266
+ "'markdown' (default) or 'json' (typed { tests: [...] })."
1267
+ ),
1268
+ },
1269
+ async ({ symbol, response_format }) => {
1270
+ try {
1271
+ const tests = testsForSymbol(symbol);
1272
+ const fresh = indexFreshness();
1273
+ const note = freshnessNote(fresh);
1274
+
1275
+ if (response_format === 'json') {
1276
+ return jsonResult({ symbol, test_count: tests.length, tests: tests.map(chunkCard), index: fresh });
1277
+ }
1278
+ if (tests.length === 0) {
1279
+ const known = db.resolveSymbol(symbol).length > 0;
1280
+ const parts = [known
1281
+ ? `🧪 No tests found that reference \`${symbol}\`. It may be untested, or its tests are dynamic/indirect (the test calls a wrapper, not the symbol directly).`
1282
+ : `Symbol \`${symbol}\` is not in the index. Try search_code(query="${symbol}").`];
1283
+ if (note) parts.push(note);
1284
+ return { content: [{ type: 'text', text: parts.join('\n') }] };
1285
+ }
1286
+ const lines = [`# 🧪 Tests for \`${symbol}\` — ${tests.length}`];
1287
+ for (const t of tests) {
1288
+ lines.push(`- \`${t.class_context ? t.class_context + '.' : ''}${t.name}\` [${t.node_type}]`
1289
+ + ` in \`${t.file_path}\` (lines ${t.start_line}–${t.end_line}) · id \`${t.id}\``);
1290
+ }
1291
+ if (note) lines.push('', note);
1292
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1293
+ } catch (err) {
1294
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
1295
+ }
1296
+ }
1297
+ );
1298
+
1299
+ // ─── explain_symbol ─────────────────────────────────────────────────────────────
1300
+ server.tool(
1301
+ 'explain_symbol',
1302
+ 'One-call overview of a symbol before you edit it: its signature(s), what it calls (callees), '
1303
+ + 'who calls it (callers / blast radius), subclasses and type users, the HTTP routes it handles, '
1304
+ + 'the tests that exercise it, and git recency/co-change. Composes resolve_symbol + '
1305
+ + 'find_references + find_routes + tests_for + git signals so an agent gets the full context in '
1306
+ + 'a single round-trip instead of four.',
1307
+ {
1308
+ symbol: z.string().describe("Exact symbol name (function, class, interface, method)."),
1309
+ target_class: z.string().optional().describe(
1310
+ "Optional owning class/type to scope the caller/reference dimensions when several symbols share the name."
1311
+ ),
1312
+ response_format: z.enum(['markdown', 'json']).default('markdown').describe(
1313
+ "'markdown' (default) or 'json' (typed { definitions, callees, called_by, subclassed_by, used_as_type_by, routes, tests, recent_changes, co_changes })."
1314
+ ),
1315
+ },
1316
+ async ({ symbol, target_class, response_format }) => {
1317
+ try {
1318
+ const defs = db.resolveSymbol(symbol);
1319
+ if (defs.length === 0) {
1320
+ const indexEmpty = db.chunkCount() === 0;
1321
+ const emptyHint = 'Index is empty — run `npm run mcp:index` (or `idx-index --repo <path>`) to build it.';
1322
+ return response_format === 'json'
1323
+ ? jsonResult({ symbol, found: false, ...(indexEmpty ? { index_status: 'empty', hint: emptyHint } : {}), definitions: [] })
1324
+ : { content: [{ type: 'text', text: indexEmpty ? `⚠️ ${emptyHint}` : `Symbol \`${symbol}\` not in index. Try search_code(query="${symbol}").` }] };
1325
+ }
1326
+
1327
+ const { targetDefs, ambiguous, calls, inherits, types, references } =
1328
+ findReferences(db, symbol, { targetClass: target_class || null });
1329
+ const scipRefs = references || [];
1330
+ const callees = [...new Set(defs.flatMap(d => d.calls || []))].sort();
1331
+ const tests = testsForSymbol(symbol);
1332
+ const defIds = new Set(defs.map(d => d.id));
1333
+ const routes = findRoutes(db, {}).filter(r => r.handler_name === symbol || (r.id && defIds.has(r.id)));
1334
+ const files = [...new Set(targetDefs.map(d => d.file_path))];
1335
+ const coChanges = coChangeFiles(gitSignals, files);
1336
+ const recentChanges = files
1337
+ .map(f => ({ file: f, hotness: Number(gitBoostScore(gitSignals, f).toFixed(3)) }))
1338
+ .filter(r => r.hotness > 0)
1339
+ .sort((a, b) => b.hotness - a.hotness || a.file.localeCompare(b.file));
1340
+ const fresh = indexFreshness();
1341
+ const note = freshnessNote(fresh);
1342
+
1343
+ if (response_format === 'json') {
1344
+ return jsonResult({
1345
+ symbol,
1346
+ target_class: target_class || null,
1347
+ ambiguous,
1348
+ definition_count: defs.length,
1349
+ definitions: defs.map(d => {
1350
+ const cen = db.getCentrality?.(d.id);
1351
+ return { ...chunkCard(d), signature: extractSignatureLine(d.code_snippet), ...(cen ? { centrality: cen } : {}) };
1352
+ }),
1353
+ callees,
1354
+ called_by: {
1355
+ high: calls.high.map(h => refCard({ ...h, confidence: 'high' })),
1356
+ name_only: calls.nameOnly.map(n => refCard({ ...n, confidence: 'name-only' })),
1357
+ },
1358
+ subclassed_by: inherits.map(refCard),
1359
+ used_as_type_by: types.map(refCard),
1360
+ referenced_by_resolved: scipRefs.map(refCard),
1361
+ routes,
1362
+ tests: tests.map(chunkCard),
1363
+ recent_changes: recentChanges,
1364
+ co_changes: coChanges,
1365
+ index: fresh,
1366
+ });
1367
+ }
1368
+
1369
+ const callerTotal = calls.high.length + calls.nameOnly.length;
1370
+ const lines = [`# 🔎 \`${target_class ? target_class + '.' : ''}${symbol}\``];
1371
+ if (ambiguous) lines.push(`> ⚠️ ${defs.length} symbols named \`${symbol}\`${target_class ? '' : ' — pass target_class to scope callers/references'}.`);
1372
+ for (const d of defs) {
1373
+ const cen = db.getCentrality?.(d.id);
1374
+ const cenTag = cen ? ` · 🎯 centrality #${cen.rank}/${cen.total}${cen.rank <= Math.ceil(cen.total * 0.1) ? ' (hub)' : ''}` : '';
1375
+ lines.push('', `**${d.class_context ? d.class_context + '.' : ''}${d.name}** [${d.node_type}] · \`${d.file_path}\`:${d.start_line}–${d.end_line} · id \`${d.id}\`${cenTag}`);
1376
+ lines.push('```', extractSignatureLine(d.code_snippet), '```');
1377
+ }
1378
+ if (callees.length) lines.push('', `**Calls (${callees.length}):** ${callees.slice(0, 20).join(', ')}${callees.length > 20 ? ' …' : ''}`);
1379
+ lines.push('', `**Called by (${callerTotal}):** ${calls.high.length} high-confidence` + (calls.nameOnly.length ? `, ${calls.nameOnly.length} name-only` : ''));
1380
+ for (const h of calls.high.slice(0, 10)) lines.push(` - ✅ \`${h.chunk.class_context ? h.chunk.class_context + '.' : ''}${h.chunk.name}\` in \`${h.chunk.file_path}\`${h.recvHint ? ` · via ${h.recvHint}` : ''}`);
1381
+ if (inherits.length) lines.push('', `**Subclassed/implemented by (${inherits.length}):** ` + inherits.slice(0, 10).map(r => `\`${r.chunk.name}\``).join(', '));
1382
+ if (types.length) lines.push('', `**Used as a type by (${types.length}):** ` + types.slice(0, 10).map(r => `\`${r.chunk.name}\``).join(', '));
1383
+ if (scipRefs.length) lines.push('', `**🎯 SCIP-resolved references (${scipRefs.length}):** ` + scipRefs.slice(0, 10).map(r => `\`${r.chunk.name}\``).join(', '));
1384
+ if (routes.length) {
1385
+ lines.push('', `**Routes (${routes.length}):**`);
1386
+ for (const r of routes) lines.push(` - **${r.method}** \`${r.path}\``);
1387
+ }
1388
+ lines.push('', tests.length ? `**Tests (${tests.length}):**` : `**Tests:** none found`);
1389
+ for (const t of tests.slice(0, 10)) lines.push(` - 🧪 \`${t.name}\` in \`${t.file_path}\` · id \`${t.id}\``);
1390
+ if (recentChanges.length) lines.push('', `**Recent activity:** ` + recentChanges.map(r => `\`${r.file}\` (hotness ${r.hotness})`).join(', '));
1391
+ if (coChanges.length) lines.push('', coChangeLine(coChanges));
1392
+ if (note) lines.push('', note);
1393
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1394
+ } catch (err) {
1395
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
1396
+ }
1397
+ }
1398
+ );
1399
+
1400
+ // ─── impact_of_edit ─────────────────────────────────────────────────────────────
1401
+ server.tool(
1402
+ 'impact_of_edit',
1403
+ 'The precise BLAST RADIUS of a change before you make it: pass the symbols and/or files '
1404
+ + 'you are about to edit and get the transitively-affected code (callers, subclasses, type '
1405
+ + 'users), the HTTP routes that reach them, the tests to run, and the files that '
1406
+ + 'historically change together. Follows high-confidence edges transitively (precise, no '
1407
+ + 'name collisions); direct ambiguous referrers are listed separately to verify. Most '
1408
+ + 'precise on an index built with --symbol-graph; otherwise resolves at query time.',
1409
+ {
1410
+ symbols: z.array(z.string()).optional().describe('Symbol names about to change (functions, classes, methods).'),
1411
+ files: z.array(z.string()).optional().describe('Repo-relative file paths about to change (every defined symbol in them seeds the impact).'),
1412
+ max_depth: z.number().int().min(1).max(6).default(3).describe('Transitive caller hops to follow (default 3).'),
1413
+ max_nodes: z.number().int().min(1).max(1000).default(200).describe('Cap on affected chunks returned.'),
1414
+ precision: z.enum(['standard', 'strict']).default('standard').describe(
1415
+ "'standard' (default): follow the high-confidence closure (resolved + import/proximity). "
1416
+ + "'strict': follow ONLY provably-unambiguous bindings (sole definition or type-pinned receiver) "
1417
+ + 'for a false-positive-free blast radius — most useful on a --symbol-graph --resolver precise index.'
1418
+ ),
1419
+ response_format: z.enum(['markdown', 'json']).default('markdown').describe(
1420
+ "'markdown' (default) or 'json' (typed { changed, impacted, ambiguous, routes, tests, co_changes })."
1421
+ ),
1422
+ },
1423
+ async ({ symbols = [], files = [], max_depth, max_nodes, precision = 'standard', response_format }) => {
1424
+ try {
1425
+ // Seed: every definition of each symbol + every chunk in each named file.
1426
+ const seedMap = new Map();
1427
+ for (const s of symbols) for (const c of db.resolveSymbol(s)) seedMap.set(c.id, c);
1428
+ for (const f of files) for (const c of db.getChunksByFile(f)) seedMap.set(c.id, c);
1429
+ const seed = [...seedMap.values()];
1430
+
1431
+ if (seed.length === 0) {
1432
+ const msg = (symbols.length || files.length)
1433
+ ? `No indexed symbols matched ${JSON.stringify([...symbols, ...files])}. Check the names/paths, or run search_code.`
1434
+ : 'Pass `symbols` and/or `files` you are about to change.';
1435
+ return response_format === 'json'
1436
+ ? jsonResult({ changed: [], impacted: [], ambiguous: [], routes: [], tests: [], co_changes: [], note: msg })
1437
+ : { content: [{ type: 'text', text: msg }] };
1438
+ }
1439
+
1440
+ const { impacted, ambiguous, truncated, usedGraph } = buildImpact(db, seed, { maxDepth: max_depth, maxNodes: max_nodes, precision });
1441
+
1442
+ // Affected = seed ∪ impacted. Split tests out from production code.
1443
+ const affected = [...seed.map(c => ({ chunk: c, depth: 0, kind: 'seed' })), ...impacted];
1444
+ const affectedIds = new Set(affected.map(a => a.chunk.id));
1445
+ const isTest = (c) => TEST_FILE_RE.test(c.file_path);
1446
+ const impactedCode = impacted.filter(a => !isTest(a.chunk));
1447
+ const tests = affected.filter(a => isTest(a.chunk)).map(a => a.chunk);
1448
+
1449
+ // Routes whose handler chunk is in the affected set.
1450
+ const routes = findRoutes(db, {}).filter(r => r.id && affectedIds.has(r.id));
1451
+ const seedFiles = [...new Set(seed.map(c => c.file_path))];
1452
+ const coChanges = coChangeFiles(gitSignals, seedFiles);
1453
+ const fresh = indexFreshness();
1454
+ const note = freshnessNote(fresh);
1455
+
1456
+ if (response_format === 'json') {
1457
+ return jsonResult({
1458
+ seed_symbols: symbols, seed_files: files,
1459
+ resolution: usedGraph ? 'symbol-graph' : 'query-time',
1460
+ precision,
1461
+ changed: seed.map(chunkCard),
1462
+ impacted: impactedCode.map(a => ({ ...chunkCard(a.chunk), depth: a.depth, via: a.kind })),
1463
+ ambiguous: ambiguous.map(a => ({ ...chunkCard(a.chunk), via: a.kind })),
1464
+ routes,
1465
+ tests: tests.map(chunkCard),
1466
+ co_changes: coChanges,
1467
+ truncated,
1468
+ index: fresh,
1469
+ });
1470
+ }
1471
+
1472
+ const ref = (c) => `\`${c.class_context ? c.class_context + '.' : ''}${c.name}\` in \`${c.file_path}\`:${c.start_line}`;
1473
+ const lines = [`# 💥 Impact of editing ${seed.map(c => `\`${c.name}\``).slice(0, 8).join(', ')}`];
1474
+ lines.push(`> resolution: ${usedGraph ? 'symbol-graph (precise)' : 'query-time'} · precision: ${precision}${truncated ? ` · ⚠️ truncated at ${max_nodes} nodes` : ''}`);
1475
+ lines.push('', `**Changed (${seed.length}):** ${seed.map(c => `\`${c.name}\``).join(', ')}`);
1476
+ lines.push('', `**Impacted code (${impactedCode.length})** — transitive high-confidence callers/users:`);
1477
+ for (const a of impactedCode.slice(0, 40)) lines.push(` - [d${a.depth}·${a.kind}] ${ref(a.chunk)}`);
1478
+ if (impactedCode.length > 40) lines.push(` …and ${impactedCode.length - 40} more`);
1479
+ lines.push('', tests.length ? `**Tests to run (${tests.length}):**` : `**Tests to run:** none found`);
1480
+ for (const t of tests.slice(0, 20)) lines.push(` - 🧪 ${ref(t)} · id \`${t.id}\``);
1481
+ if (routes.length) {
1482
+ lines.push('', `**Affected routes (${routes.length}):**`);
1483
+ for (const r of routes) lines.push(` - **${r.method}** \`${r.path}\` → \`${r.handler_name}\``);
1484
+ }
1485
+ if (ambiguous.length) {
1486
+ lines.push('', `**Ambiguous (${ambiguous.length}) — verify (same-named symbol, not expanded):**`);
1487
+ for (const a of ambiguous.slice(0, 15)) lines.push(` - ❔ ${ref(a.chunk)}`);
1488
+ }
1489
+ if (coChanges.length) lines.push('', coChangeLine(coChanges));
1490
+ if (!usedGraph) lines.push('', '> ℹ️ Build the index with `--symbol-graph` for a precomputed, chunk-precise blast radius.');
1491
+ if (note) lines.push('', note);
1492
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1493
+ } catch (err) {
1494
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
1495
+ }
1496
+ }
1497
+ );
1498
+
1499
+ // ─── trace_taint ─────────────────────────────────────────────────────────────────
1500
+ server.tool(
1501
+ 'trace_taint',
1502
+ 'Security: trace UNTRUSTED data from a source (request body/query/params, argv/env, stdin, '
1503
+ + 'socket/file reads) to a dangerous SINK (eval/exec, SQL, fs/path, HTML, outbound request) '
1504
+ + 'across the call graph — surfacing injection-class risks (rce/sqli/xss/path/ssrf) with the '
1505
+ + 'concrete source→sink path. Same-function flows are high-confidence; cross-function '
1506
+ + 'reachability is medium; a sanitizer on the path lowers it. FINDER, not a verifier — it '
1507
+ + 'misses dynamic dispatch / reflection / ORM indirection, so "no flows" ≠ "safe". '
1508
+ + 'JS/TS + Python. Most precise on a `--symbol-graph` index.',
1509
+ {
1510
+ source_kind: z.string().optional().describe("Restrict to one source kind (e.g. 'http-request', 'process-input', 'stdin')."),
1511
+ category: z.enum(['rce', 'sqli', 'xss', 'path', 'ssrf']).optional().describe('Restrict to one sink category.'),
1512
+ max_depth: z.number().int().min(1).max(8).default(4).describe('Cross-function call hops to follow from a source (default 4).'),
1513
+ max_flows: z.number().int().min(1).max(500).default(100).describe('Cap on flows returned (default 100).'),
1514
+ response_format: z.enum(['markdown', 'json']).default('markdown').describe("'markdown' (default) or 'json' (typed { flows: [{ source, sink, path, via, confidence, sanitized }] })."),
1515
+ },
1516
+ async ({ source_kind, category, max_depth, max_flows, response_format }) => {
1517
+ try {
1518
+ const { flows, truncated, scanned } = traceTaint(db, {
1519
+ sourceKind: source_kind || null, sinkCategory: category || null, maxDepth: max_depth, maxFlows: max_flows,
1520
+ });
1521
+ if (response_format === 'json') {
1522
+ return jsonResult({ source_kind: source_kind || null, category: category || null, scanned, flow_count: flows.length, truncated, flows });
1523
+ }
1524
+ const lines = [`# 🧪 Taint flows — ${flows.length}${truncated ? '+' : ''} found (scanned ${scanned} chunks)`, ''];
1525
+ if (!flows.length) lines.push('No source→sink flows matched. ⚠️ This is a finder, not a verifier — absence of a flow is not proof of safety.');
1526
+ for (const f of flows.slice(0, 40)) {
1527
+ lines.push(`- **${f.sink.category.toUpperCase()}** [${f.confidence}${f.sanitized ? ' · sanitizer on path' : ''}] — \`${f.source.kind}\` → \`${f.sink.label}\``);
1528
+ lines.push(` - source \`${f.source.name}\` in \`${f.source.file_path}\`:${f.source.line}`);
1529
+ lines.push(` - sink \`${f.sink.name}\` in \`${f.sink.file_path}\`:${f.sink.line} (${f.via}, depth ${f.depth})`);
1530
+ }
1531
+ if (flows.length > 40) lines.push(`\n…and ${flows.length - 40} more.`);
1532
+ lines.push('', '> Heuristic finder (JS/TS + Python). Triage each flow against its path; it can over- and under-report.');
1533
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1534
+ } catch (err) {
1535
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
1536
+ }
1537
+ }
1538
+ );
1539
+
1540
+ // ─── find_tainted_sinks ──────────────────────────────────────────────────────────
1541
+ server.tool(
1542
+ 'find_tainted_sinks',
1543
+ 'Security orientation: list every dangerous SINK in the codebase (eval/exec, SQL, fs/path, '
1544
+ + 'HTML, outbound requests) grouped by category, each flagged with whether an untrusted '
1545
+ + 'SOURCE reaches it. Use it FIRST to map the attack surface, then trace_taint a specific '
1546
+ + 'one. JS/TS + Python; same heuristic-finder caveats as trace_taint.',
1547
+ {
1548
+ category: z.enum(['rce', 'sqli', 'xss', 'path', 'ssrf']).optional().describe('Restrict to one sink category.'),
1549
+ reachable_only: z.boolean().default(false).describe('Only sinks reachable from an untrusted source.'),
1550
+ max_depth: z.number().int().min(1).max(8).default(4).describe('Reachability hops to consider (default 4).'),
1551
+ response_format: z.enum(['markdown', 'json']).default('markdown').describe("'markdown' (default) or 'json' (typed { sinks_by_category })."),
1552
+ },
1553
+ async ({ category, reachable_only, max_depth, response_format }) => {
1554
+ try {
1555
+ const { byCategory, scanned, flowCount } = findTaintedSinks(db, {
1556
+ category: category || null, reachableOnly: reachable_only, maxDepth: max_depth,
1557
+ });
1558
+ const total = Object.values(byCategory).reduce((s, a) => s + a.length, 0);
1559
+ if (response_format === 'json') {
1560
+ return jsonResult({ category: category || null, reachable_only, scanned, sink_count: total, reachable_flow_count: flowCount, sinks_by_category: byCategory });
1561
+ }
1562
+ const lines = [`# 🎯 Dangerous sinks — ${total} (scanned ${scanned} chunks)`, ''];
1563
+ if (!total) lines.push('No sinks matched these filters.');
1564
+ for (const cat of CATEGORY_SEVERITY) {
1565
+ const list = byCategory[cat];
1566
+ if (!list || !list.length) continue;
1567
+ const reached = list.filter(s => s.reached_by_source).length;
1568
+ lines.push(`## ${cat.toUpperCase()} — ${list.length}${reached ? ` (${reached} reachable from untrusted input ⚠️)` : ''}`);
1569
+ for (const s of list.slice(0, 25)) {
1570
+ lines.push(` - ${s.reached_by_source ? '⚠️ ' : ''}\`${s.label}\` in \`${s.file_path}\`:${s.line} — \`${s.name}\``);
1571
+ }
1572
+ if (list.length > 25) lines.push(` …and ${list.length - 25} more`);
1573
+ lines.push('');
1574
+ }
1575
+ lines.push('> Heuristic finder (JS/TS + Python). A flagged sink is a place to look, not a confirmed bug.');
1576
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1577
+ } catch (err) {
1578
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
1579
+ }
1580
+ }
1581
+ );
1157
1582
  }