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/CHANGELOG.md +442 -0
- package/README.md +54 -11
- package/SECURITY.md +17 -0
- package/colbert.mjs +168 -0
- package/config.mjs +246 -21
- package/embeddings.mjs +90 -8
- package/engine/binary.mjs +38 -0
- package/engine/memory.mjs +214 -1
- package/engine/sqlite.mjs +278 -5
- package/enrichment.mjs +95 -0
- package/indexer.mjs +254 -9
- package/init.mjs +25 -2
- package/mcp/centrality.mjs +117 -0
- package/mcp/resolver.mjs +116 -0
- package/mcp/symbolgraph.mjs +137 -0
- package/mcp/taint.mjs +225 -0
- package/mcp/tools.mjs +444 -19
- package/mcp/topology.mjs +138 -13
- package/mcp-server.mjs +26 -2
- package/package.json +29 -21
- package/parse/extractor.mjs +17 -6
- package/parse/interprocedural.mjs +134 -0
- package/parse/languages.mjs +221 -57
- package/parse/metadata.mjs +46 -0
- package/parse/scip.mjs +391 -0
- package/parse/taint-patterns.mjs +119 -0
- package/prompts/CORE.md +9 -4
- package/prompts/frameworks/FASTAPI_DJANGO.md +1 -0
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +1 -0
- package/prompts/frameworks/REACT.md +1 -1
- package/prompts/frameworks/SPRING_BOOT.md +1 -0
- package/prompts/languages/GO.md +1 -0
- package/prompts/languages/JAVA.md +1 -1
- package/prompts/languages/PYTHON.md +1 -1
- package/prompts/languages/RUST.md +1 -1
- package/seal.mjs +400 -0
- package/search-core.mjs +151 -1
- package/search-sparse.mjs +183 -0
- package/storage.mjs +5 -0
- package/watch-daemon.mjs +7 -2
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
: (
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
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
|
}
|