ctxo-mcp 0.2.0 → 0.3.1
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/README.md +188 -0
- package/dist/chunk-JIDIH7DS.js +28 -0
- package/dist/{chunk-54ETLIQX.js → chunk-N6GPODUY.js} +8 -4
- package/dist/chunk-N6GPODUY.js.map +1 -0
- package/dist/{chunk-P7JUSY3I.js → chunk-XSHNN6PU.js} +167 -16
- package/dist/chunk-XSHNN6PU.js.map +1 -0
- package/dist/{cli-router-PIWHLS5F.js → cli-router-NRUGPICL.js} +808 -69
- package/dist/cli-router-NRUGPICL.js.map +1 -0
- package/dist/index.js +1643 -66
- package/dist/index.js.map +1 -1
- package/dist/json-index-reader-FCKSKA6R.js +9 -0
- package/dist/json-index-reader-FCKSKA6R.js.map +1 -0
- package/dist/{staleness-detector-5AN223FM.js → staleness-detector-VSDPTPX7.js} +2 -1
- package/dist/{staleness-detector-5AN223FM.js.map → staleness-detector-VSDPTPX7.js.map} +1 -1
- package/llms-full.txt +260 -0
- package/llms.txt +53 -0
- package/package.json +7 -2
- package/dist/chunk-54ETLIQX.js.map +0 -1
- package/dist/chunk-P7JUSY3I.js.map +0 -1
- package/dist/cli-router-PIWHLS5F.js.map +0 -1
- package/dist/json-index-reader-PNLPAS42.js +0 -8
- /package/dist/{json-index-reader-PNLPAS42.js.map → chunk-JIDIH7DS.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -2,19 +2,22 @@
|
|
|
2
2
|
import {
|
|
3
3
|
RevertDetector,
|
|
4
4
|
SimpleGitAdapter,
|
|
5
|
-
SqliteStorageAdapter
|
|
6
|
-
|
|
5
|
+
SqliteStorageAdapter,
|
|
6
|
+
createLogger,
|
|
7
|
+
loadCoChangeMap
|
|
8
|
+
} from "./chunk-XSHNN6PU.js";
|
|
7
9
|
import {
|
|
8
10
|
DetailLevelSchema,
|
|
9
11
|
JsonIndexReader
|
|
10
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-N6GPODUY.js";
|
|
13
|
+
import "./chunk-JIDIH7DS.js";
|
|
11
14
|
|
|
12
15
|
// src/index.ts
|
|
13
16
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
17
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
15
|
-
import { z as
|
|
16
|
-
import { existsSync, readFileSync } from "fs";
|
|
17
|
-
import { join } from "path";
|
|
18
|
+
import { z as z15 } from "zod";
|
|
19
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
20
|
+
import { join as join2 } from "path";
|
|
18
21
|
|
|
19
22
|
// src/core/masking/masking-pipeline.ts
|
|
20
23
|
var DEFAULT_PATTERNS = [
|
|
@@ -76,9 +79,9 @@ var SymbolGraph = class {
|
|
|
76
79
|
edgeSet = /* @__PURE__ */ new Set();
|
|
77
80
|
addNode(node) {
|
|
78
81
|
this.nodes.set(node.symbolId, node);
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
81
|
-
this.nodesByFileAndName.set(`${
|
|
82
|
+
const [p0, p1] = node.symbolId.split("::");
|
|
83
|
+
if (p0 !== void 0 && p1 !== void 0) {
|
|
84
|
+
this.nodesByFileAndName.set(`${p0}::${p1}`, node);
|
|
82
85
|
}
|
|
83
86
|
}
|
|
84
87
|
addEdge(edge) {
|
|
@@ -100,18 +103,29 @@ var SymbolGraph = class {
|
|
|
100
103
|
}
|
|
101
104
|
resolveNodeId(id) {
|
|
102
105
|
if (this.nodes.has(id)) return id;
|
|
103
|
-
const
|
|
104
|
-
if (
|
|
105
|
-
const fuzzyKey = `${
|
|
106
|
+
const [p0, p1] = id.split("::");
|
|
107
|
+
if (p0 !== void 0 && p1 !== void 0) {
|
|
108
|
+
const fuzzyKey = `${p0}::${p1}`;
|
|
106
109
|
const match = this.nodesByFileAndName.get(fuzzyKey);
|
|
107
110
|
if (match) return match.symbolId;
|
|
111
|
+
const jsToTs = p0.replace(/\.js$/, ".ts");
|
|
112
|
+
if (jsToTs !== p0) {
|
|
113
|
+
const altKey = `${jsToTs}::${p1}`;
|
|
114
|
+
const altMatch = this.nodesByFileAndName.get(altKey);
|
|
115
|
+
if (altMatch) return altMatch.symbolId;
|
|
116
|
+
}
|
|
108
117
|
}
|
|
109
118
|
return id;
|
|
110
119
|
}
|
|
111
120
|
resolveNodeFuzzy(id) {
|
|
112
|
-
const
|
|
113
|
-
if (
|
|
114
|
-
|
|
121
|
+
const [p0, p1] = id.split("::");
|
|
122
|
+
if (p0 !== void 0 && p1 !== void 0) {
|
|
123
|
+
const match = this.nodesByFileAndName.get(`${p0}::${p1}`);
|
|
124
|
+
if (match) return match;
|
|
125
|
+
const jsToTs = p0.replace(/\.js$/, ".ts");
|
|
126
|
+
if (jsToTs !== p0) {
|
|
127
|
+
return this.nodesByFileAndName.get(`${jsToTs}::${p1}`);
|
|
128
|
+
}
|
|
115
129
|
}
|
|
116
130
|
return void 0;
|
|
117
131
|
}
|
|
@@ -122,7 +136,7 @@ var SymbolGraph = class {
|
|
|
122
136
|
return this.reverseEdges.get(symbolId) ?? [];
|
|
123
137
|
}
|
|
124
138
|
hasNode(symbolId) {
|
|
125
|
-
return this.nodes.has(symbolId);
|
|
139
|
+
return this.nodes.has(symbolId) || this.resolveNodeFuzzy(symbolId) !== void 0;
|
|
126
140
|
}
|
|
127
141
|
get nodeCount() {
|
|
128
142
|
return this.nodes.size;
|
|
@@ -252,6 +266,9 @@ var DetailFormatter = class {
|
|
|
252
266
|
return Math.ceil(chars / 4);
|
|
253
267
|
}
|
|
254
268
|
symbolCharEstimate(node) {
|
|
269
|
+
if (node.startOffset !== void 0 && node.endOffset !== void 0) {
|
|
270
|
+
return node.endOffset - node.startOffset;
|
|
271
|
+
}
|
|
255
272
|
return (node.endLine - node.startLine + 1) * 40;
|
|
256
273
|
}
|
|
257
274
|
truncateToTokenBudget(slice, budget) {
|
|
@@ -272,11 +289,142 @@ var DetailFormatter = class {
|
|
|
272
289
|
}
|
|
273
290
|
};
|
|
274
291
|
|
|
292
|
+
// src/core/response-envelope.ts
|
|
293
|
+
var DEFAULT_THRESHOLD = 8192;
|
|
294
|
+
var TRUNCATABLE_FIELDS = {
|
|
295
|
+
impactedSymbols: "Use search_symbols to find specific impacted symbols, or get_blast_radius with a confidence filter.",
|
|
296
|
+
importers: "Use find_importers with edgeKinds filter or maxDepth to narrow results.",
|
|
297
|
+
deadSymbols: "Use search_symbols with kind filter to find specific dead symbols.",
|
|
298
|
+
unusedExports: "Use find_importers to check specific symbols.",
|
|
299
|
+
results: "Use search_symbols or get_ranked_context with a narrower query.",
|
|
300
|
+
rankings: "Use get_symbol_importance with kind or filePattern filter to narrow results.",
|
|
301
|
+
context: "Use get_context_for_task with a smaller tokenBudget.",
|
|
302
|
+
hierarchies: "Use get_class_hierarchy with a specific symbolId.",
|
|
303
|
+
scaffolding: "Review the scaffolding markers directly in the listed files.",
|
|
304
|
+
deadFiles: "Use find_dead_code with includeTests to adjust scope.",
|
|
305
|
+
files: "Use get_changed_symbols with a narrower since ref or smaller maxFiles."
|
|
306
|
+
};
|
|
307
|
+
function getThreshold() {
|
|
308
|
+
const env = process.env["CTXO_RESPONSE_LIMIT"];
|
|
309
|
+
if (env) {
|
|
310
|
+
const n = parseInt(env, 10);
|
|
311
|
+
if (!isNaN(n) && n > 0) return n;
|
|
312
|
+
}
|
|
313
|
+
return DEFAULT_THRESHOLD;
|
|
314
|
+
}
|
|
315
|
+
function findTruncatableArray(data) {
|
|
316
|
+
let best = null;
|
|
317
|
+
for (const [key, value] of Object.entries(data)) {
|
|
318
|
+
if (Array.isArray(value) && key in TRUNCATABLE_FIELDS) {
|
|
319
|
+
if (!best || value.length > best.arr.length) {
|
|
320
|
+
best = { key, arr: value };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return best;
|
|
325
|
+
}
|
|
326
|
+
function wrapResponse(data) {
|
|
327
|
+
const threshold = getThreshold();
|
|
328
|
+
const fullJson = JSON.stringify(data);
|
|
329
|
+
const totalBytes = Buffer.byteLength(fullJson, "utf-8");
|
|
330
|
+
const truncatable = findTruncatableArray(data);
|
|
331
|
+
const totalItems = truncatable ? truncatable.arr.length : 0;
|
|
332
|
+
if (totalBytes <= threshold) {
|
|
333
|
+
return {
|
|
334
|
+
...data,
|
|
335
|
+
_meta: {
|
|
336
|
+
totalItems,
|
|
337
|
+
returnedItems: totalItems,
|
|
338
|
+
truncated: false,
|
|
339
|
+
totalBytes
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
if (!truncatable || truncatable.arr.length <= 1) {
|
|
344
|
+
return {
|
|
345
|
+
...data,
|
|
346
|
+
_meta: {
|
|
347
|
+
totalItems,
|
|
348
|
+
returnedItems: totalItems,
|
|
349
|
+
truncated: false,
|
|
350
|
+
totalBytes
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
let lo = 1;
|
|
355
|
+
let hi = truncatable.arr.length;
|
|
356
|
+
while (lo < hi) {
|
|
357
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
358
|
+
const trial = {
|
|
359
|
+
...data,
|
|
360
|
+
[truncatable.key]: truncatable.arr.slice(0, mid),
|
|
361
|
+
_meta: {
|
|
362
|
+
totalItems,
|
|
363
|
+
returnedItems: mid,
|
|
364
|
+
truncated: true,
|
|
365
|
+
totalBytes,
|
|
366
|
+
hint: TRUNCATABLE_FIELDS[truncatable.key]
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
const size = Buffer.byteLength(JSON.stringify(trial), "utf-8");
|
|
370
|
+
if (size <= threshold) {
|
|
371
|
+
lo = mid;
|
|
372
|
+
} else {
|
|
373
|
+
hi = mid - 1;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const hint = TRUNCATABLE_FIELDS[truncatable.key];
|
|
377
|
+
return {
|
|
378
|
+
...data,
|
|
379
|
+
[truncatable.key]: truncatable.arr.slice(0, lo),
|
|
380
|
+
_meta: {
|
|
381
|
+
totalItems,
|
|
382
|
+
returnedItems: lo,
|
|
383
|
+
truncated: true,
|
|
384
|
+
totalBytes,
|
|
385
|
+
hint
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// src/core/intent-filter.ts
|
|
391
|
+
function extractKeywords(intent) {
|
|
392
|
+
return intent.toLowerCase().split(/\s+/).filter((w) => w.length >= 2);
|
|
393
|
+
}
|
|
394
|
+
function matchesAny(keywords, values) {
|
|
395
|
+
const lower = values.map((v) => v.toLowerCase());
|
|
396
|
+
return keywords.some((kw) => lower.some((v) => v.includes(kw)));
|
|
397
|
+
}
|
|
398
|
+
function collectMatchableStrings(entry) {
|
|
399
|
+
const strings = [];
|
|
400
|
+
for (const key of ["symbolId", "file", "name", "kind", "edgeKind", "reason", "confidence"]) {
|
|
401
|
+
const v = entry[key];
|
|
402
|
+
if (typeof v === "string") strings.push(v);
|
|
403
|
+
}
|
|
404
|
+
const edgeKinds = entry["edgeKinds"];
|
|
405
|
+
if (Array.isArray(edgeKinds)) {
|
|
406
|
+
for (const ek of edgeKinds) {
|
|
407
|
+
if (typeof ek === "string") strings.push(ek);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return strings;
|
|
411
|
+
}
|
|
412
|
+
function filterByIntent(items, intent) {
|
|
413
|
+
if (!intent || intent.trim().length === 0) return items;
|
|
414
|
+
const keywords = extractKeywords(intent);
|
|
415
|
+
if (keywords.length === 0) return items;
|
|
416
|
+
return items.filter((item) => {
|
|
417
|
+
const strings = collectMatchableStrings(item);
|
|
418
|
+
return matchesAny(keywords, strings);
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
275
422
|
// src/adapters/mcp/get-logic-slice.ts
|
|
276
423
|
var InputSchema = z.object({
|
|
277
424
|
symbolId: z.string().min(1).optional(),
|
|
278
425
|
symbolIds: z.array(z.string().min(1)).optional(),
|
|
279
|
-
level: DetailLevelSchema.optional().default(3)
|
|
426
|
+
level: DetailLevelSchema.optional().default(3),
|
|
427
|
+
intent: z.string().optional().describe('Filter dependencies by intent keywords (e.g., "core", "adapter", "storage")')
|
|
280
428
|
}).refine(
|
|
281
429
|
(data) => data.symbolId || data.symbolIds && data.symbolIds.length > 0,
|
|
282
430
|
{ message: "Either symbolId or symbolIds must be provided" }
|
|
@@ -323,20 +471,27 @@ function handleGetLogicSlice(storage, masking, staleness, ctxoRoot = ".ctxo") {
|
|
|
323
471
|
content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
|
|
324
472
|
};
|
|
325
473
|
}
|
|
326
|
-
const { symbolId, symbolIds, level } = parsed.data;
|
|
474
|
+
const { symbolId, symbolIds, level, intent } = parsed.data;
|
|
327
475
|
const ids = symbolIds ?? (symbolId ? [symbolId] : []);
|
|
328
476
|
const graph = getGraph();
|
|
329
477
|
const results = [];
|
|
330
478
|
for (const id of ids) {
|
|
331
479
|
const slice = query.getLogicSlice(graph, id);
|
|
332
480
|
if (slice) {
|
|
333
|
-
|
|
481
|
+
const formatted = formatter.format(slice, level);
|
|
482
|
+
if (intent && Array.isArray(formatted["dependencies"])) {
|
|
483
|
+
formatted["dependencies"] = filterByIntent(
|
|
484
|
+
formatted["dependencies"],
|
|
485
|
+
intent
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
results.push(formatted);
|
|
334
489
|
} else {
|
|
335
490
|
results.push({ found: false, symbolId: id, hint: 'Symbol not found. Run "ctxo index".' });
|
|
336
491
|
}
|
|
337
492
|
}
|
|
338
493
|
const responseData = ids.length === 1 ? results[0] : { batch: true, results };
|
|
339
|
-
const payload = masking.mask(JSON.stringify(responseData));
|
|
494
|
+
const payload = masking.mask(JSON.stringify(wrapResponse(responseData)));
|
|
340
495
|
const content = [];
|
|
341
496
|
if (staleness) {
|
|
342
497
|
const warning = staleness.check(storage.listIndexedFiles());
|
|
@@ -358,7 +513,8 @@ function handleGetLogicSlice(storage, masking, staleness, ctxoRoot = ".ctxo") {
|
|
|
358
513
|
// src/adapters/mcp/get-why-context.ts
|
|
359
514
|
import { z as z2 } from "zod";
|
|
360
515
|
var InputSchema2 = z2.object({
|
|
361
|
-
symbolId: z2.string().min(1)
|
|
516
|
+
symbolId: z2.string().min(1),
|
|
517
|
+
maxCommits: z2.number().int().min(1).optional()
|
|
362
518
|
});
|
|
363
519
|
function handleGetWhyContext(storage, git, masking, staleness, ctxoRoot = ".ctxo") {
|
|
364
520
|
const revertDetector = new RevertDetector();
|
|
@@ -371,7 +527,7 @@ function handleGetWhyContext(storage, git, masking, staleness, ctxoRoot = ".ctxo
|
|
|
371
527
|
content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
|
|
372
528
|
};
|
|
373
529
|
}
|
|
374
|
-
const { symbolId } = parsed.data;
|
|
530
|
+
const { symbolId, maxCommits } = parsed.data;
|
|
375
531
|
const symbol = storage.getSymbolById(symbolId);
|
|
376
532
|
if (!symbol) {
|
|
377
533
|
return {
|
|
@@ -396,8 +552,9 @@ function handleGetWhyContext(storage, git, masking, staleness, ctxoRoot = ".ctxo
|
|
|
396
552
|
}));
|
|
397
553
|
antiPatterns = revertDetector.detect(commits);
|
|
398
554
|
}
|
|
555
|
+
const limitedHistory = maxCommits ? commitHistory.slice(0, maxCommits) : commitHistory;
|
|
399
556
|
const responsePayload = {
|
|
400
|
-
commitHistory,
|
|
557
|
+
commitHistory: limitedHistory,
|
|
401
558
|
antiPatternWarnings: antiPatterns
|
|
402
559
|
};
|
|
403
560
|
if (antiPatterns.length > 0) {
|
|
@@ -511,44 +668,97 @@ function handleGetChangeIntelligence(storage, git, masking, staleness, ctxoRoot
|
|
|
511
668
|
import { z as z4 } from "zod";
|
|
512
669
|
|
|
513
670
|
// src/core/blast-radius/blast-radius-calculator.ts
|
|
671
|
+
var CONFIRMED_KINDS = /* @__PURE__ */ new Set(["calls", "extends", "implements"]);
|
|
672
|
+
var LIKELY_KINDS = /* @__PURE__ */ new Set(["uses"]);
|
|
673
|
+
var CONFIDENCE_RANK = {
|
|
674
|
+
confirmed: 2,
|
|
675
|
+
likely: 1,
|
|
676
|
+
potential: 0
|
|
677
|
+
};
|
|
678
|
+
function edgeKindToConfidence(kind) {
|
|
679
|
+
if (CONFIRMED_KINDS.has(kind)) return "confirmed";
|
|
680
|
+
if (LIKELY_KINDS.has(kind)) return "likely";
|
|
681
|
+
return "potential";
|
|
682
|
+
}
|
|
514
683
|
var BlastRadiusCalculator = class {
|
|
515
|
-
calculate(graph, symbolId) {
|
|
684
|
+
calculate(graph, symbolId, coChangeMap) {
|
|
516
685
|
if (!graph.hasNode(symbolId)) {
|
|
517
|
-
return { impactedSymbols: [], directDependentsCount: 0, overallRiskScore: 0 };
|
|
686
|
+
return { impactedSymbols: [], directDependentsCount: 0, confirmedCount: 0, likelyCount: 0, potentialCount: 0, overallRiskScore: 0 };
|
|
518
687
|
}
|
|
688
|
+
const sourceFile = symbolId.split("::")[0];
|
|
519
689
|
const visited = /* @__PURE__ */ new Set([symbolId]);
|
|
520
|
-
const
|
|
690
|
+
const rawEntries = [];
|
|
521
691
|
const queue = [{ id: symbolId, depth: 0 }];
|
|
522
692
|
while (queue.length > 0) {
|
|
523
693
|
const current = queue.shift();
|
|
524
694
|
const reverseEdges = graph.getReverseEdges(current.id);
|
|
695
|
+
const bestByNode = /* @__PURE__ */ new Map();
|
|
525
696
|
for (const edge of reverseEdges) {
|
|
526
697
|
if (visited.has(edge.from)) continue;
|
|
527
|
-
|
|
528
|
-
|
|
698
|
+
const conf = edgeKindToConfidence(edge.kind);
|
|
699
|
+
const existing = bestByNode.get(edge.from);
|
|
700
|
+
if (!existing) {
|
|
701
|
+
bestByNode.set(edge.from, { confidence: conf, kinds: /* @__PURE__ */ new Set([edge.kind]) });
|
|
702
|
+
} else {
|
|
703
|
+
existing.kinds.add(edge.kind);
|
|
704
|
+
if (CONFIDENCE_RANK[conf] > CONFIDENCE_RANK[existing.confidence]) {
|
|
705
|
+
existing.confidence = conf;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
for (const [nodeId, info] of bestByNode) {
|
|
710
|
+
visited.add(nodeId);
|
|
711
|
+
if (!graph.hasNode(nodeId)) continue;
|
|
529
712
|
const depth = current.depth + 1;
|
|
530
713
|
const riskScore = 1 / Math.pow(depth, 0.7);
|
|
531
|
-
|
|
532
|
-
|
|
714
|
+
let confidence = info.confidence;
|
|
715
|
+
let coChangeFrequency;
|
|
716
|
+
if (coChangeMap) {
|
|
717
|
+
const nodeFile = nodeId.split("::")[0];
|
|
718
|
+
const coEntries = coChangeMap.get(sourceFile);
|
|
719
|
+
if (coEntries) {
|
|
720
|
+
const match = coEntries.find((e) => e.file1 === nodeFile || e.file2 === nodeFile);
|
|
721
|
+
if (match) {
|
|
722
|
+
coChangeFrequency = match.frequency;
|
|
723
|
+
if (confidence === "potential" && match.frequency > 0.5) {
|
|
724
|
+
confidence = "likely";
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
rawEntries.push({
|
|
730
|
+
symbolId: nodeId,
|
|
533
731
|
depth,
|
|
534
|
-
|
|
535
|
-
|
|
732
|
+
riskScore: Math.round(riskScore * 1e3) / 1e3,
|
|
733
|
+
confidence,
|
|
734
|
+
edgeKinds: [...info.kinds],
|
|
735
|
+
coChangeFrequency
|
|
536
736
|
});
|
|
537
|
-
queue.push({ id:
|
|
737
|
+
queue.push({ id: nodeId, depth });
|
|
538
738
|
}
|
|
539
739
|
}
|
|
740
|
+
const blastSet = new Set(rawEntries.map((e) => e.symbolId));
|
|
741
|
+
blastSet.add(symbolId);
|
|
742
|
+
const entries = rawEntries.map((e) => ({
|
|
743
|
+
...e,
|
|
744
|
+
dependentCount: graph.getReverseEdges(e.symbolId).filter((re) => blastSet.has(re.from)).length
|
|
745
|
+
}));
|
|
540
746
|
entries.sort((a, b) => a.depth - b.depth);
|
|
541
747
|
const directDependentsCount = entries.filter((e) => e.depth === 1).length;
|
|
542
748
|
const totalRisk = entries.reduce((sum, e) => sum + e.riskScore, 0);
|
|
543
|
-
const
|
|
544
|
-
const
|
|
545
|
-
|
|
749
|
+
const overallRiskScore = Math.round(Math.min(totalRisk / Math.max(directDependentsCount, 1), 1) * 1e3) / 1e3;
|
|
750
|
+
const confirmedCount = entries.filter((e) => e.confidence === "confirmed").length;
|
|
751
|
+
const likelyCount = entries.filter((e) => e.confidence === "likely").length;
|
|
752
|
+
const potentialCount = entries.filter((e) => e.confidence === "potential").length;
|
|
753
|
+
return { impactedSymbols: entries, directDependentsCount, confirmedCount, likelyCount, potentialCount, overallRiskScore };
|
|
546
754
|
}
|
|
547
755
|
};
|
|
548
756
|
|
|
549
757
|
// src/adapters/mcp/get-blast-radius.ts
|
|
550
758
|
var InputSchema4 = z4.object({
|
|
551
|
-
symbolId: z4.string().min(1)
|
|
759
|
+
symbolId: z4.string().min(1),
|
|
760
|
+
confidence: z4.enum(["confirmed", "likely", "potential"]).optional(),
|
|
761
|
+
intent: z4.string().optional().describe('Filter results by intent keywords (e.g., "test", "adapter", "security")')
|
|
552
762
|
});
|
|
553
763
|
function handleGetBlastRadius(storage, masking, staleness, ctxoRoot = ".ctxo") {
|
|
554
764
|
const calculator = new BlastRadiusCalculator();
|
|
@@ -573,13 +783,24 @@ function handleGetBlastRadius(storage, masking, staleness, ctxoRoot = ".ctxo") {
|
|
|
573
783
|
};
|
|
574
784
|
}
|
|
575
785
|
const result = calculator.calculate(graph, symbolId);
|
|
576
|
-
|
|
786
|
+
let symbols = result.impactedSymbols;
|
|
787
|
+
if (parsed.data.confidence) {
|
|
788
|
+
symbols = symbols.filter((s) => s.confidence === parsed.data.confidence);
|
|
789
|
+
}
|
|
790
|
+
symbols = filterByIntent(symbols, parsed.data.intent);
|
|
791
|
+
const confirmedCount = symbols.filter((s) => s.confidence === "confirmed").length;
|
|
792
|
+
const likelyCount = symbols.filter((s) => s.confidence === "likely").length;
|
|
793
|
+
const potentialCount = symbols.filter((s) => s.confidence === "potential").length;
|
|
794
|
+
const payload = masking.mask(JSON.stringify(wrapResponse({
|
|
577
795
|
symbolId,
|
|
578
|
-
impactScore:
|
|
796
|
+
impactScore: symbols.length,
|
|
579
797
|
directDependentsCount: result.directDependentsCount,
|
|
798
|
+
confirmedCount,
|
|
799
|
+
likelyCount,
|
|
800
|
+
potentialCount,
|
|
580
801
|
overallRiskScore: result.overallRiskScore,
|
|
581
|
-
impactedSymbols:
|
|
582
|
-
}));
|
|
802
|
+
impactedSymbols: symbols
|
|
803
|
+
})));
|
|
583
804
|
const content = [];
|
|
584
805
|
if (staleness) {
|
|
585
806
|
const warning = staleness.check(storage.listIndexedFiles());
|
|
@@ -688,17 +909,1228 @@ function handleGetArchitecturalOverlay(storage, masking, staleness) {
|
|
|
688
909
|
};
|
|
689
910
|
}
|
|
690
911
|
|
|
912
|
+
// src/adapters/mcp/get-dead-code.ts
|
|
913
|
+
import { z as z6 } from "zod";
|
|
914
|
+
|
|
915
|
+
// src/core/dead-code/dead-code-detector.ts
|
|
916
|
+
var TEST_PATTERNS = [/__tests__/, /\.test\.ts$/, /\.spec\.ts$/, /\btests\//, /\bfixtures?\//];
|
|
917
|
+
var CONFIG_PATTERNS = [/\.(config|rc)\.(ts|js|json)$/, /tsconfig/, /eslint/, /\.d\.ts$/];
|
|
918
|
+
var FRAMEWORK_PATTERNS = [
|
|
919
|
+
// Vitest
|
|
920
|
+
/^(describe|it|expect|beforeAll|beforeEach|afterAll|afterEach|vi)$/,
|
|
921
|
+
// MCP SDK
|
|
922
|
+
/^(registerTool|registerPrompt|connect|close)$/,
|
|
923
|
+
// Zod schemas (conventionally exported for validation)
|
|
924
|
+
/Schema$/,
|
|
925
|
+
// Node.js lifecycle
|
|
926
|
+
/^main$/
|
|
927
|
+
];
|
|
928
|
+
var SCAFFOLDING_PATTERNS = [
|
|
929
|
+
{ regex: /\bTODO\b/i, pattern: "TODO" },
|
|
930
|
+
{ regex: /\bFIXME\b/i, pattern: "FIXME" },
|
|
931
|
+
{ regex: /\bHACK\b/i, pattern: "HACK" },
|
|
932
|
+
{ regex: /\bPLACEHOLDER\b/i, pattern: "PLACEHOLDER" },
|
|
933
|
+
{ regex: /\bXXX\b/, pattern: "XXX" },
|
|
934
|
+
{ regex: /Phase\s+\d|Step\s+\d/i, pattern: "PHASE/STEP" },
|
|
935
|
+
{ regex: /not\s+(?:yet\s+)?implement/i, pattern: "NOT_IMPLEMENTED" },
|
|
936
|
+
{ regex: /temporary|temp\s+fix/i, pattern: "TEMPORARY" }
|
|
937
|
+
];
|
|
938
|
+
var DeadCodeDetector = class {
|
|
939
|
+
detect(graph, options = {}) {
|
|
940
|
+
const allNodes = graph.allNodes();
|
|
941
|
+
const candidates = options.includeTests ? allNodes : allNodes.filter((n) => {
|
|
942
|
+
const file = n.symbolId.split("::")[0] ?? "";
|
|
943
|
+
return !this.matchesAny(file, TEST_PATTERNS) && !this.matchesAny(file, CONFIG_PATTERNS);
|
|
944
|
+
});
|
|
945
|
+
if (candidates.length === 0) {
|
|
946
|
+
return { totalSymbols: 0, reachableSymbols: 0, deadSymbols: [], unusedExports: [], deadFiles: [], scaffolding: this.detectScaffolding(options.sourceContents), deadCodePercentage: 0 };
|
|
947
|
+
}
|
|
948
|
+
const candidateIds = new Set(candidates.map((n) => n.symbolId));
|
|
949
|
+
const entryPoints = /* @__PURE__ */ new Set();
|
|
950
|
+
for (const node of candidates) {
|
|
951
|
+
if (this.isFrameworkSymbol(node.name)) {
|
|
952
|
+
entryPoints.add(node.symbolId);
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
const reverseEdges = graph.getReverseEdges(node.symbolId);
|
|
956
|
+
const forwardEdges = graph.getForwardEdges(node.symbolId);
|
|
957
|
+
const incomingFromCandidates = reverseEdges.filter((e) => candidateIds.has(e.from) && e.from !== node.symbolId);
|
|
958
|
+
if (incomingFromCandidates.length === 0) {
|
|
959
|
+
const hasOutgoing = forwardEdges.some((e) => candidateIds.has(e.to));
|
|
960
|
+
if (hasOutgoing || reverseEdges.length > 0) {
|
|
961
|
+
entryPoints.add(node.symbolId);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
const reachable = /* @__PURE__ */ new Set();
|
|
966
|
+
const queue = [...entryPoints];
|
|
967
|
+
while (queue.length > 0) {
|
|
968
|
+
const current = queue.shift();
|
|
969
|
+
if (reachable.has(current)) continue;
|
|
970
|
+
reachable.add(current);
|
|
971
|
+
for (const edge of graph.getForwardEdges(current)) {
|
|
972
|
+
if (!reachable.has(edge.to) && candidateIds.has(edge.to)) {
|
|
973
|
+
queue.push(edge.to);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
const deadIds = /* @__PURE__ */ new Set();
|
|
978
|
+
for (const node of candidates) {
|
|
979
|
+
if (!reachable.has(node.symbolId)) {
|
|
980
|
+
deadIds.add(node.symbolId);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
const cascadeDepthMap = this.computeCascadeDepths(graph, deadIds, candidateIds);
|
|
984
|
+
const deadSymbols = [];
|
|
985
|
+
for (const node of candidates) {
|
|
986
|
+
if (!deadIds.has(node.symbolId)) continue;
|
|
987
|
+
const file = node.symbolId.split("::")[0] ?? "";
|
|
988
|
+
const confidence = this.computeConfidence(graph, node.symbolId, deadIds);
|
|
989
|
+
const cascadeDepth = cascadeDepthMap.get(node.symbolId);
|
|
990
|
+
deadSymbols.push({
|
|
991
|
+
symbolId: node.symbolId,
|
|
992
|
+
file,
|
|
993
|
+
name: node.name,
|
|
994
|
+
kind: node.kind,
|
|
995
|
+
confidence,
|
|
996
|
+
reason: this.describeReason(confidence),
|
|
997
|
+
...cascadeDepth !== void 0 && cascadeDepth > 0 ? { cascadeDepth } : {}
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
const fileStats = /* @__PURE__ */ new Map();
|
|
1001
|
+
for (const node of candidates) {
|
|
1002
|
+
const file = node.symbolId.split("::")[0] ?? "";
|
|
1003
|
+
const stats = fileStats.get(file) ?? { total: 0, dead: 0 };
|
|
1004
|
+
stats.total++;
|
|
1005
|
+
if (deadIds.has(node.symbolId)) stats.dead++;
|
|
1006
|
+
fileStats.set(file, stats);
|
|
1007
|
+
}
|
|
1008
|
+
const deadFiles = [...fileStats.entries()].filter(([, s]) => s.total > 0 && s.dead === s.total).map(([file]) => file);
|
|
1009
|
+
const unusedExports = [];
|
|
1010
|
+
for (const node of candidates) {
|
|
1011
|
+
const reverseEdges = graph.getReverseEdges(node.symbolId);
|
|
1012
|
+
const externalImporters = reverseEdges.filter(
|
|
1013
|
+
(e) => candidateIds.has(e.from) && e.from !== node.symbolId
|
|
1014
|
+
);
|
|
1015
|
+
if (externalImporters.length === 0 && !deadIds.has(node.symbolId)) {
|
|
1016
|
+
unusedExports.push({
|
|
1017
|
+
symbolId: node.symbolId,
|
|
1018
|
+
file: node.symbolId.split("::")[0] ?? "",
|
|
1019
|
+
name: node.name,
|
|
1020
|
+
kind: node.kind
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
const scaffolding = this.detectScaffolding(options.sourceContents);
|
|
1025
|
+
const deadCodePercentage = Math.round(deadSymbols.length / candidates.length * 100 * 10) / 10;
|
|
1026
|
+
return {
|
|
1027
|
+
totalSymbols: candidates.length,
|
|
1028
|
+
reachableSymbols: reachable.size,
|
|
1029
|
+
deadSymbols,
|
|
1030
|
+
unusedExports,
|
|
1031
|
+
deadFiles,
|
|
1032
|
+
scaffolding,
|
|
1033
|
+
deadCodePercentage
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
computeConfidence(graph, symbolId, deadIds) {
|
|
1037
|
+
const reverseEdges = graph.getReverseEdges(symbolId);
|
|
1038
|
+
if (reverseEdges.length === 0) {
|
|
1039
|
+
return 1;
|
|
1040
|
+
}
|
|
1041
|
+
const allImportersExcluded = reverseEdges.every((e) => {
|
|
1042
|
+
const importerFile = e.from.split("::")[0] ?? "";
|
|
1043
|
+
return this.matchesAny(importerFile, TEST_PATTERNS) || this.matchesAny(importerFile, CONFIG_PATTERNS);
|
|
1044
|
+
});
|
|
1045
|
+
if (allImportersExcluded) {
|
|
1046
|
+
return 0.9;
|
|
1047
|
+
}
|
|
1048
|
+
const allImportersDead = reverseEdges.every((e) => deadIds.has(e.from));
|
|
1049
|
+
if (allImportersDead) {
|
|
1050
|
+
return 0.7;
|
|
1051
|
+
}
|
|
1052
|
+
return 0.7;
|
|
1053
|
+
}
|
|
1054
|
+
describeReason(confidence) {
|
|
1055
|
+
switch (confidence) {
|
|
1056
|
+
case 1:
|
|
1057
|
+
return "Zero importers \u2014 no code references this symbol";
|
|
1058
|
+
case 0.9:
|
|
1059
|
+
return "Only referenced from test/config files";
|
|
1060
|
+
case 0.7:
|
|
1061
|
+
return "All importers are themselves dead (cascading)";
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
isFrameworkSymbol(name) {
|
|
1065
|
+
return FRAMEWORK_PATTERNS.some((p) => p.test(name));
|
|
1066
|
+
}
|
|
1067
|
+
computeCascadeDepths(graph, deadIds, candidateIds) {
|
|
1068
|
+
const depths = /* @__PURE__ */ new Map();
|
|
1069
|
+
const rootDead = /* @__PURE__ */ new Set();
|
|
1070
|
+
for (const id of deadIds) {
|
|
1071
|
+
const reverseEdges = graph.getReverseEdges(id);
|
|
1072
|
+
const deadImporters = reverseEdges.filter((e) => deadIds.has(e.from) && e.from !== id);
|
|
1073
|
+
if (deadImporters.length === 0) {
|
|
1074
|
+
rootDead.add(id);
|
|
1075
|
+
depths.set(id, 0);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
const queue = [...rootDead].map((id) => ({ id, depth: 0 }));
|
|
1079
|
+
const visited = new Set(rootDead);
|
|
1080
|
+
while (queue.length > 0) {
|
|
1081
|
+
const current = queue.shift();
|
|
1082
|
+
for (const edge of graph.getForwardEdges(current.id)) {
|
|
1083
|
+
if (deadIds.has(edge.to) && !visited.has(edge.to) && candidateIds.has(edge.to)) {
|
|
1084
|
+
const newDepth = current.depth + 1;
|
|
1085
|
+
depths.set(edge.to, newDepth);
|
|
1086
|
+
visited.add(edge.to);
|
|
1087
|
+
queue.push({ id: edge.to, depth: newDepth });
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
return depths;
|
|
1092
|
+
}
|
|
1093
|
+
detectScaffolding(sourceContents) {
|
|
1094
|
+
if (!sourceContents || sourceContents.size === 0) return [];
|
|
1095
|
+
const results = [];
|
|
1096
|
+
for (const [file, content] of sourceContents) {
|
|
1097
|
+
if (this.matchesAny(file, TEST_PATTERNS) || this.matchesAny(file, CONFIG_PATTERNS)) continue;
|
|
1098
|
+
const lines = content.split("\n");
|
|
1099
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1100
|
+
const line = lines[i];
|
|
1101
|
+
for (const { regex, pattern } of SCAFFOLDING_PATTERNS) {
|
|
1102
|
+
if (regex.test(line)) {
|
|
1103
|
+
results.push({
|
|
1104
|
+
file,
|
|
1105
|
+
line: i + 1,
|
|
1106
|
+
pattern,
|
|
1107
|
+
text: line.trim().slice(0, 120)
|
|
1108
|
+
});
|
|
1109
|
+
break;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
return results;
|
|
1115
|
+
}
|
|
1116
|
+
matchesAny(value, patterns) {
|
|
1117
|
+
return patterns.some((p) => p.test(value));
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
1120
|
+
|
|
1121
|
+
// src/adapters/mcp/get-dead-code.ts
|
|
1122
|
+
var InputSchema6 = z6.object({
|
|
1123
|
+
includeTests: z6.boolean().optional().default(false),
|
|
1124
|
+
intent: z6.string().optional().describe('Filter dead code results by intent keywords (e.g., "adapter", "core", "function")')
|
|
1125
|
+
});
|
|
1126
|
+
function handleFindDeadCode(storage, masking, staleness, ctxoRoot = ".ctxo") {
|
|
1127
|
+
const detector = new DeadCodeDetector();
|
|
1128
|
+
const getGraph = () => {
|
|
1129
|
+
const jsonGraph = buildGraphFromJsonIndex(ctxoRoot);
|
|
1130
|
+
if (jsonGraph.nodeCount > 0) return jsonGraph;
|
|
1131
|
+
return buildGraphFromStorage(storage);
|
|
1132
|
+
};
|
|
1133
|
+
return (args) => {
|
|
1134
|
+
try {
|
|
1135
|
+
const parsed = InputSchema6.safeParse(args);
|
|
1136
|
+
if (!parsed.success) {
|
|
1137
|
+
return {
|
|
1138
|
+
content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
const graph = getGraph();
|
|
1142
|
+
const result = detector.detect(graph, { includeTests: parsed.data.includeTests });
|
|
1143
|
+
const filtered = {
|
|
1144
|
+
...result,
|
|
1145
|
+
deadSymbols: filterByIntent(result.deadSymbols, parsed.data.intent)
|
|
1146
|
+
};
|
|
1147
|
+
const payload = masking.mask(JSON.stringify(wrapResponse(filtered)));
|
|
1148
|
+
const content = [];
|
|
1149
|
+
if (staleness) {
|
|
1150
|
+
const warning = staleness.check(storage.listIndexedFiles());
|
|
1151
|
+
if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
|
|
1152
|
+
}
|
|
1153
|
+
content.push({ type: "text", text: payload });
|
|
1154
|
+
return { content };
|
|
1155
|
+
} catch (err) {
|
|
1156
|
+
return {
|
|
1157
|
+
content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// src/adapters/mcp/get-context-for-task.ts
|
|
1164
|
+
import { z as z7 } from "zod";
|
|
1165
|
+
|
|
1166
|
+
// src/core/context-assembly/task-context-strategy.ts
|
|
1167
|
+
var TASK_WEIGHTS = {
|
|
1168
|
+
fix: {
|
|
1169
|
+
directDependency: 0.3,
|
|
1170
|
+
interfaceTypeDef: 0.1,
|
|
1171
|
+
blastRadiusDependent: 0.1,
|
|
1172
|
+
highComplexity: 0.2,
|
|
1173
|
+
antiPatternHistory: 0.3
|
|
1174
|
+
},
|
|
1175
|
+
extend: {
|
|
1176
|
+
directDependency: 0.4,
|
|
1177
|
+
interfaceTypeDef: 0.5,
|
|
1178
|
+
blastRadiusDependent: 0.1,
|
|
1179
|
+
highComplexity: 0,
|
|
1180
|
+
antiPatternHistory: 0
|
|
1181
|
+
},
|
|
1182
|
+
refactor: {
|
|
1183
|
+
directDependency: 0.2,
|
|
1184
|
+
interfaceTypeDef: 0.1,
|
|
1185
|
+
blastRadiusDependent: 0.5,
|
|
1186
|
+
highComplexity: 0.1,
|
|
1187
|
+
antiPatternHistory: 0.1
|
|
1188
|
+
},
|
|
1189
|
+
understand: {
|
|
1190
|
+
directDependency: 0.5,
|
|
1191
|
+
interfaceTypeDef: 0.3,
|
|
1192
|
+
blastRadiusDependent: 0.1,
|
|
1193
|
+
highComplexity: 0.1,
|
|
1194
|
+
antiPatternHistory: 0
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
function getWeightsForTask(taskType) {
|
|
1198
|
+
return TASK_WEIGHTS[taskType];
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// src/core/context-assembly/context-assembler.ts
|
|
1202
|
+
var DEFAULT_TOKEN_BUDGET = 4e3;
|
|
1203
|
+
var ContextAssembler = class {
|
|
1204
|
+
logicSlice = new LogicSliceQuery();
|
|
1205
|
+
blastRadius = new BlastRadiusCalculator();
|
|
1206
|
+
assembleForTask(graph, symbolId, taskType, indices, tokenBudget = DEFAULT_TOKEN_BUDGET) {
|
|
1207
|
+
const target = graph.getNode(symbolId);
|
|
1208
|
+
if (!target) return void 0;
|
|
1209
|
+
const weights = getWeightsForTask(taskType);
|
|
1210
|
+
const slice = this.logicSlice.getLogicSlice(graph, symbolId);
|
|
1211
|
+
const blastResult = this.blastRadius.calculate(graph, symbolId);
|
|
1212
|
+
const candidateMap = /* @__PURE__ */ new Map();
|
|
1213
|
+
if (slice) {
|
|
1214
|
+
for (const dep of slice.dependencies) {
|
|
1215
|
+
candidateMap.set(dep.symbolId, {
|
|
1216
|
+
node: dep,
|
|
1217
|
+
signals: { isDirectDep: true, isInterfaceOrType: this.isInterfaceOrType(dep.kind), isDependent: false, complexity: 0, hasAntiPattern: false }
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
for (const entry of blastResult.impactedSymbols) {
|
|
1222
|
+
const node = graph.getNode(entry.symbolId);
|
|
1223
|
+
if (!node) continue;
|
|
1224
|
+
const existing = candidateMap.get(entry.symbolId);
|
|
1225
|
+
if (existing) {
|
|
1226
|
+
existing.signals.isDependent = true;
|
|
1227
|
+
} else {
|
|
1228
|
+
candidateMap.set(entry.symbolId, {
|
|
1229
|
+
node,
|
|
1230
|
+
signals: { isDirectDep: false, isInterfaceOrType: this.isInterfaceOrType(node.kind), isDependent: true, complexity: 0, hasAntiPattern: false }
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
const fileIndexMap = new Map(indices.map((i) => [i.file, i]));
|
|
1235
|
+
for (const [sid, candidate] of candidateMap) {
|
|
1236
|
+
const file = sid.split("::")[0] ?? "";
|
|
1237
|
+
const fileIndex = fileIndexMap.get(file);
|
|
1238
|
+
if (fileIndex) {
|
|
1239
|
+
const complexity = fileIndex.complexity?.find((c) => c.symbolId === sid);
|
|
1240
|
+
candidate.signals.complexity = complexity?.cyclomatic ?? 0;
|
|
1241
|
+
candidate.signals.hasAntiPattern = fileIndex.antiPatterns.length > 0;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
const scored = [...candidateMap.entries()].map(([, { node, signals }]) => ({
|
|
1245
|
+
entry: this.buildContextEntry(node, signals, weights),
|
|
1246
|
+
score: this.computeScore(signals, weights)
|
|
1247
|
+
}));
|
|
1248
|
+
scored.sort((a, b) => b.score - a.score);
|
|
1249
|
+
const context = [];
|
|
1250
|
+
let totalTokens = 0;
|
|
1251
|
+
for (const { entry } of scored) {
|
|
1252
|
+
if (totalTokens + entry.tokens > tokenBudget) continue;
|
|
1253
|
+
context.push(entry);
|
|
1254
|
+
totalTokens += entry.tokens;
|
|
1255
|
+
}
|
|
1256
|
+
const warnings = [];
|
|
1257
|
+
const targetFile = symbolId.split("::")[0] ?? "";
|
|
1258
|
+
const targetIndex = fileIndexMap.get(targetFile);
|
|
1259
|
+
if (targetIndex && targetIndex.antiPatterns.length > 0) {
|
|
1260
|
+
warnings.push(`\u26A0 Target symbol has ${targetIndex.antiPatterns.length} anti-pattern(s) in file history`);
|
|
1261
|
+
}
|
|
1262
|
+
return { target, taskType, context, totalTokens, tokenBudget, warnings };
|
|
1263
|
+
}
|
|
1264
|
+
assembleRanked(graph, query, strategy = "combined", tokenBudget = DEFAULT_TOKEN_BUDGET) {
|
|
1265
|
+
const allNodes = graph.allNodes();
|
|
1266
|
+
const queryLower = query.toLowerCase();
|
|
1267
|
+
let maxReverseEdges = 1;
|
|
1268
|
+
for (const node of allNodes) {
|
|
1269
|
+
const count = graph.getReverseEdges(node.symbolId).length;
|
|
1270
|
+
if (count > maxReverseEdges) maxReverseEdges = count;
|
|
1271
|
+
}
|
|
1272
|
+
const scored = allNodes.map((node) => {
|
|
1273
|
+
const relevanceScore = this.computeTextRelevance(node.name, queryLower);
|
|
1274
|
+
const importanceScore = graph.getReverseEdges(node.symbolId).length / maxReverseEdges;
|
|
1275
|
+
let combinedScore;
|
|
1276
|
+
switch (strategy) {
|
|
1277
|
+
case "dependency":
|
|
1278
|
+
combinedScore = relevanceScore;
|
|
1279
|
+
break;
|
|
1280
|
+
case "importance":
|
|
1281
|
+
combinedScore = importanceScore;
|
|
1282
|
+
break;
|
|
1283
|
+
default:
|
|
1284
|
+
combinedScore = relevanceScore * 0.6 + importanceScore * 0.4;
|
|
1285
|
+
}
|
|
1286
|
+
return {
|
|
1287
|
+
symbolId: node.symbolId,
|
|
1288
|
+
name: node.name,
|
|
1289
|
+
kind: node.kind,
|
|
1290
|
+
file: node.symbolId.split("::")[0] ?? "",
|
|
1291
|
+
relevanceScore: Math.round(relevanceScore * 1e3) / 1e3,
|
|
1292
|
+
importanceScore: Math.round(importanceScore * 1e3) / 1e3,
|
|
1293
|
+
combinedScore: Math.round(combinedScore * 1e3) / 1e3,
|
|
1294
|
+
tokens: this.estimateTokens(node)
|
|
1295
|
+
};
|
|
1296
|
+
});
|
|
1297
|
+
const filtered = strategy === "importance" ? scored : scored.filter((s) => s.relevanceScore > 0 || s.importanceScore > 0.1);
|
|
1298
|
+
filtered.sort((a, b) => b.combinedScore - a.combinedScore);
|
|
1299
|
+
const results = [];
|
|
1300
|
+
let totalTokens = 0;
|
|
1301
|
+
for (const item of filtered) {
|
|
1302
|
+
if (totalTokens + item.tokens > tokenBudget) continue;
|
|
1303
|
+
results.push(item);
|
|
1304
|
+
totalTokens += item.tokens;
|
|
1305
|
+
}
|
|
1306
|
+
return { query, strategy, results, totalTokens, tokenBudget };
|
|
1307
|
+
}
|
|
1308
|
+
computeTextRelevance(name, queryLower) {
|
|
1309
|
+
const nameLower = name.toLowerCase();
|
|
1310
|
+
if (nameLower === queryLower) return 1;
|
|
1311
|
+
if (nameLower.includes(queryLower)) return 0.7;
|
|
1312
|
+
if (queryLower.split(/\s+/).some((word) => nameLower.includes(word))) return 0.4;
|
|
1313
|
+
return 0;
|
|
1314
|
+
}
|
|
1315
|
+
computeScore(signals, weights) {
|
|
1316
|
+
let score = 0;
|
|
1317
|
+
if (signals.isDirectDep) score += weights.directDependency;
|
|
1318
|
+
if (signals.isInterfaceOrType) score += weights.interfaceTypeDef;
|
|
1319
|
+
if (signals.isDependent) score += weights.blastRadiusDependent;
|
|
1320
|
+
if (signals.complexity > 5) score += weights.highComplexity;
|
|
1321
|
+
if (signals.hasAntiPattern) score += weights.antiPatternHistory;
|
|
1322
|
+
return Math.round(score * 1e3) / 1e3;
|
|
1323
|
+
}
|
|
1324
|
+
buildContextEntry(node, signals, weights) {
|
|
1325
|
+
const reasons = [];
|
|
1326
|
+
if (signals.isDirectDep) reasons.push("direct dependency");
|
|
1327
|
+
if (signals.isInterfaceOrType) reasons.push("type/interface definition");
|
|
1328
|
+
if (signals.isDependent) reasons.push("blast radius dependent");
|
|
1329
|
+
if (signals.complexity > 5) reasons.push(`high complexity (CC=${signals.complexity})`);
|
|
1330
|
+
if (signals.hasAntiPattern) reasons.push("anti-pattern history");
|
|
1331
|
+
return {
|
|
1332
|
+
symbolId: node.symbolId,
|
|
1333
|
+
name: node.name,
|
|
1334
|
+
kind: node.kind,
|
|
1335
|
+
file: node.symbolId.split("::")[0] ?? "",
|
|
1336
|
+
relevanceScore: this.computeScore(signals, weights),
|
|
1337
|
+
reason: reasons.join(", ") || "related symbol",
|
|
1338
|
+
lines: node.endLine - node.startLine + 1,
|
|
1339
|
+
tokens: this.estimateTokens(node)
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
isInterfaceOrType(kind) {
|
|
1343
|
+
return kind === "interface" || kind === "type";
|
|
1344
|
+
}
|
|
1345
|
+
estimateTokens(node) {
|
|
1346
|
+
if (node.startOffset !== void 0 && node.endOffset !== void 0) {
|
|
1347
|
+
return Math.ceil((node.endOffset - node.startOffset) / 4);
|
|
1348
|
+
}
|
|
1349
|
+
return (node.endLine - node.startLine + 1) * 10;
|
|
1350
|
+
}
|
|
1351
|
+
};
|
|
1352
|
+
|
|
1353
|
+
// src/adapters/mcp/get-context-for-task.ts
|
|
1354
|
+
var TaskTypeSchema = z7.enum(["fix", "extend", "refactor", "understand"]);
|
|
1355
|
+
var InputSchema7 = z7.object({
|
|
1356
|
+
symbolId: z7.string().min(1),
|
|
1357
|
+
taskType: TaskTypeSchema,
|
|
1358
|
+
tokenBudget: z7.number().min(100).optional().default(4e3)
|
|
1359
|
+
});
|
|
1360
|
+
function handleGetContextForTask(storage, masking, staleness, ctxoRoot = ".ctxo") {
|
|
1361
|
+
const assembler = new ContextAssembler();
|
|
1362
|
+
const indexReader = new JsonIndexReader(ctxoRoot);
|
|
1363
|
+
const getGraph = () => {
|
|
1364
|
+
const jsonGraph = buildGraphFromJsonIndex(ctxoRoot);
|
|
1365
|
+
if (jsonGraph.nodeCount > 0) return jsonGraph;
|
|
1366
|
+
return buildGraphFromStorage(storage);
|
|
1367
|
+
};
|
|
1368
|
+
return (args) => {
|
|
1369
|
+
try {
|
|
1370
|
+
const parsed = InputSchema7.safeParse(args);
|
|
1371
|
+
if (!parsed.success) {
|
|
1372
|
+
return {
|
|
1373
|
+
content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
const { symbolId, taskType, tokenBudget } = parsed.data;
|
|
1377
|
+
const graph = getGraph();
|
|
1378
|
+
const indices = indexReader.readAll();
|
|
1379
|
+
const result = assembler.assembleForTask(graph, symbolId, taskType, indices, tokenBudget);
|
|
1380
|
+
if (!result) {
|
|
1381
|
+
return {
|
|
1382
|
+
content: [{ type: "text", text: JSON.stringify({ found: false, hint: 'Symbol not found. Run "ctxo index".' }) }]
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
const payload = masking.mask(JSON.stringify(wrapResponse(result)));
|
|
1386
|
+
const content = [];
|
|
1387
|
+
if (staleness) {
|
|
1388
|
+
const warning = staleness.check(storage.listIndexedFiles());
|
|
1389
|
+
if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
|
|
1390
|
+
}
|
|
1391
|
+
content.push({ type: "text", text: payload });
|
|
1392
|
+
return { content };
|
|
1393
|
+
} catch (err) {
|
|
1394
|
+
return {
|
|
1395
|
+
content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// src/adapters/mcp/get-ranked-context.ts
|
|
1402
|
+
import { z as z8 } from "zod";
|
|
1403
|
+
var InputSchema8 = z8.object({
|
|
1404
|
+
query: z8.string().min(1),
|
|
1405
|
+
tokenBudget: z8.number().min(100).optional().default(4e3),
|
|
1406
|
+
strategy: z8.enum(["combined", "dependency", "importance"]).optional().default("combined")
|
|
1407
|
+
});
|
|
1408
|
+
function handleGetRankedContext(storage, masking, staleness, ctxoRoot = ".ctxo") {
|
|
1409
|
+
const assembler = new ContextAssembler();
|
|
1410
|
+
const getGraph = () => {
|
|
1411
|
+
const jsonGraph = buildGraphFromJsonIndex(ctxoRoot);
|
|
1412
|
+
if (jsonGraph.nodeCount > 0) return jsonGraph;
|
|
1413
|
+
return buildGraphFromStorage(storage);
|
|
1414
|
+
};
|
|
1415
|
+
return (args) => {
|
|
1416
|
+
try {
|
|
1417
|
+
const parsed = InputSchema8.safeParse(args);
|
|
1418
|
+
if (!parsed.success) {
|
|
1419
|
+
return {
|
|
1420
|
+
content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
const { query, tokenBudget, strategy } = parsed.data;
|
|
1424
|
+
const graph = getGraph();
|
|
1425
|
+
const result = assembler.assembleRanked(graph, query, strategy, tokenBudget);
|
|
1426
|
+
const payload = masking.mask(JSON.stringify(wrapResponse(result)));
|
|
1427
|
+
const content = [];
|
|
1428
|
+
if (staleness) {
|
|
1429
|
+
const warning = staleness.check(storage.listIndexedFiles());
|
|
1430
|
+
if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
|
|
1431
|
+
}
|
|
1432
|
+
content.push({ type: "text", text: payload });
|
|
1433
|
+
return { content };
|
|
1434
|
+
} catch (err) {
|
|
1435
|
+
return {
|
|
1436
|
+
content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// src/adapters/mcp/search-symbols.ts
|
|
1443
|
+
import { z as z9 } from "zod";
|
|
1444
|
+
var InputSchema9 = z9.object({
|
|
1445
|
+
pattern: z9.string().min(1),
|
|
1446
|
+
kind: z9.enum(["function", "class", "interface", "method", "variable", "type"]).optional(),
|
|
1447
|
+
filePattern: z9.string().optional(),
|
|
1448
|
+
limit: z9.number().int().min(1).max(100).optional().default(25)
|
|
1449
|
+
});
|
|
1450
|
+
function handleSearchSymbols(storage, masking, staleness, ctxoRoot = ".ctxo") {
|
|
1451
|
+
const getGraph = () => {
|
|
1452
|
+
const jsonGraph = buildGraphFromJsonIndex(ctxoRoot);
|
|
1453
|
+
if (jsonGraph.nodeCount > 0) return jsonGraph;
|
|
1454
|
+
return buildGraphFromStorage(storage);
|
|
1455
|
+
};
|
|
1456
|
+
return (args) => {
|
|
1457
|
+
try {
|
|
1458
|
+
const parsed = InputSchema9.safeParse(args);
|
|
1459
|
+
if (!parsed.success) {
|
|
1460
|
+
return {
|
|
1461
|
+
content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
const { pattern, kind, filePattern, limit } = parsed.data;
|
|
1465
|
+
const graph = getGraph();
|
|
1466
|
+
const allNodes = graph.allNodes();
|
|
1467
|
+
let matcher;
|
|
1468
|
+
try {
|
|
1469
|
+
const regex = new RegExp(pattern, "i");
|
|
1470
|
+
matcher = (name) => regex.test(name);
|
|
1471
|
+
} catch {
|
|
1472
|
+
const lowerPattern = pattern.toLowerCase();
|
|
1473
|
+
matcher = (name) => name.toLowerCase().includes(lowerPattern);
|
|
1474
|
+
}
|
|
1475
|
+
const matches = allNodes.filter((node) => {
|
|
1476
|
+
if (!matcher(node.name)) return false;
|
|
1477
|
+
if (kind && node.kind !== kind) return false;
|
|
1478
|
+
if (filePattern) {
|
|
1479
|
+
const file = node.symbolId.split("::")[0] ?? "";
|
|
1480
|
+
if (!file.toLowerCase().includes(filePattern.toLowerCase())) return false;
|
|
1481
|
+
}
|
|
1482
|
+
return true;
|
|
1483
|
+
});
|
|
1484
|
+
const results = matches.slice(0, limit).map((node) => ({
|
|
1485
|
+
symbolId: node.symbolId,
|
|
1486
|
+
name: node.name,
|
|
1487
|
+
kind: node.kind,
|
|
1488
|
+
file: node.symbolId.split("::")[0],
|
|
1489
|
+
startLine: node.startLine,
|
|
1490
|
+
endLine: node.endLine
|
|
1491
|
+
}));
|
|
1492
|
+
const payload = masking.mask(JSON.stringify(wrapResponse({
|
|
1493
|
+
totalMatches: matches.length,
|
|
1494
|
+
results
|
|
1495
|
+
})));
|
|
1496
|
+
const content = [];
|
|
1497
|
+
if (staleness) {
|
|
1498
|
+
const warning = staleness.check(storage.listIndexedFiles());
|
|
1499
|
+
if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
|
|
1500
|
+
}
|
|
1501
|
+
content.push({ type: "text", text: payload });
|
|
1502
|
+
return { content };
|
|
1503
|
+
} catch (err) {
|
|
1504
|
+
return {
|
|
1505
|
+
content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// src/adapters/mcp/get-changed-symbols.ts
|
|
1512
|
+
import { z as z10 } from "zod";
|
|
1513
|
+
var InputSchema10 = z10.object({
|
|
1514
|
+
since: z10.string().optional().default("HEAD~1"),
|
|
1515
|
+
maxFiles: z10.number().int().min(1).optional().default(50)
|
|
1516
|
+
});
|
|
1517
|
+
function handleGetChangedSymbols(storage, git, masking, staleness, ctxoRoot = ".ctxo") {
|
|
1518
|
+
const getGraph = () => {
|
|
1519
|
+
const jsonGraph = buildGraphFromJsonIndex(ctxoRoot);
|
|
1520
|
+
if (jsonGraph.nodeCount > 0) return jsonGraph;
|
|
1521
|
+
return buildGraphFromStorage(storage);
|
|
1522
|
+
};
|
|
1523
|
+
return async (args) => {
|
|
1524
|
+
try {
|
|
1525
|
+
const parsed = InputSchema10.safeParse(args);
|
|
1526
|
+
if (!parsed.success) {
|
|
1527
|
+
return {
|
|
1528
|
+
content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
const { since, maxFiles } = parsed.data;
|
|
1532
|
+
const changedPaths = await git.getChangedFiles(since);
|
|
1533
|
+
const graph = getGraph();
|
|
1534
|
+
const nodesByFile = /* @__PURE__ */ new Map();
|
|
1535
|
+
for (const node of graph.allNodes()) {
|
|
1536
|
+
const file = node.symbolId.split("::")[0] ?? "";
|
|
1537
|
+
const existing = nodesByFile.get(file) ?? [];
|
|
1538
|
+
existing.push({ symbolId: node.symbolId, name: node.name, kind: node.kind, startLine: node.startLine, endLine: node.endLine });
|
|
1539
|
+
nodesByFile.set(file, existing);
|
|
1540
|
+
}
|
|
1541
|
+
const files = [];
|
|
1542
|
+
let totalSymbols = 0;
|
|
1543
|
+
for (const filePath of changedPaths.slice(0, maxFiles)) {
|
|
1544
|
+
const symbols = nodesByFile.get(filePath);
|
|
1545
|
+
if (symbols && symbols.length > 0) {
|
|
1546
|
+
files.push({ file: filePath, symbols });
|
|
1547
|
+
totalSymbols += symbols.length;
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
const payload = masking.mask(JSON.stringify(wrapResponse({
|
|
1551
|
+
since,
|
|
1552
|
+
changedFiles: files.length,
|
|
1553
|
+
changedSymbols: totalSymbols,
|
|
1554
|
+
files
|
|
1555
|
+
})));
|
|
1556
|
+
const content = [];
|
|
1557
|
+
if (staleness) {
|
|
1558
|
+
const warning = staleness.check(storage.listIndexedFiles());
|
|
1559
|
+
if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
|
|
1560
|
+
}
|
|
1561
|
+
content.push({ type: "text", text: payload });
|
|
1562
|
+
return { content };
|
|
1563
|
+
} catch (err) {
|
|
1564
|
+
return {
|
|
1565
|
+
content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// src/adapters/mcp/find-importers.ts
|
|
1572
|
+
import { z as z11 } from "zod";
|
|
1573
|
+
var InputSchema11 = z11.object({
|
|
1574
|
+
symbolId: z11.string().min(1),
|
|
1575
|
+
edgeKinds: z11.array(z11.enum(["imports", "calls", "extends", "implements", "uses"])).optional(),
|
|
1576
|
+
transitive: z11.boolean().optional().default(false),
|
|
1577
|
+
maxDepth: z11.number().int().min(1).max(10).optional().default(5),
|
|
1578
|
+
intent: z11.string().optional().describe('Filter results by intent keywords (e.g., "test", "adapter", "core")')
|
|
1579
|
+
});
|
|
1580
|
+
function handleFindImporters(storage, masking, staleness, ctxoRoot = ".ctxo") {
|
|
1581
|
+
const getGraph = () => {
|
|
1582
|
+
const jsonGraph = buildGraphFromJsonIndex(ctxoRoot);
|
|
1583
|
+
if (jsonGraph.nodeCount > 0) return jsonGraph;
|
|
1584
|
+
return buildGraphFromStorage(storage);
|
|
1585
|
+
};
|
|
1586
|
+
return (args) => {
|
|
1587
|
+
try {
|
|
1588
|
+
const parsed = InputSchema11.safeParse(args);
|
|
1589
|
+
if (!parsed.success) {
|
|
1590
|
+
return {
|
|
1591
|
+
content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
const { symbolId, edgeKinds, transitive, maxDepth } = parsed.data;
|
|
1595
|
+
const graph = getGraph();
|
|
1596
|
+
if (!graph.hasNode(symbolId)) {
|
|
1597
|
+
return {
|
|
1598
|
+
content: [{ type: "text", text: JSON.stringify({ found: false, hint: 'Symbol not found. Run "ctxo index" to build the codebase index.' }) }]
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1601
|
+
const importers = [];
|
|
1602
|
+
if (!transitive) {
|
|
1603
|
+
const reverseEdges = graph.getReverseEdges(symbolId);
|
|
1604
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1605
|
+
for (const edge of reverseEdges) {
|
|
1606
|
+
if (edgeKinds && !edgeKinds.includes(edge.kind)) continue;
|
|
1607
|
+
const node = graph.getNode(edge.from);
|
|
1608
|
+
if (node) {
|
|
1609
|
+
const existing = seen.get(node.symbolId);
|
|
1610
|
+
if (existing) {
|
|
1611
|
+
if (!existing.edgeKinds.includes(edge.kind)) {
|
|
1612
|
+
existing.edgeKinds.push(edge.kind);
|
|
1613
|
+
}
|
|
1614
|
+
} else {
|
|
1615
|
+
const entry = {
|
|
1616
|
+
symbolId: node.symbolId,
|
|
1617
|
+
name: node.name,
|
|
1618
|
+
kind: node.kind,
|
|
1619
|
+
file: node.symbolId.split("::")[0] ?? "",
|
|
1620
|
+
edgeKind: edge.kind,
|
|
1621
|
+
depth: 1
|
|
1622
|
+
};
|
|
1623
|
+
seen.set(node.symbolId, { node: entry, edgeKinds: [edge.kind] });
|
|
1624
|
+
importers.push(entry);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
} else {
|
|
1629
|
+
const visited = /* @__PURE__ */ new Set([symbolId]);
|
|
1630
|
+
const queue = [{ id: symbolId, depth: 0 }];
|
|
1631
|
+
while (queue.length > 0) {
|
|
1632
|
+
const current = queue.shift();
|
|
1633
|
+
if (current.depth >= maxDepth) continue;
|
|
1634
|
+
const reverseEdges = graph.getReverseEdges(current.id);
|
|
1635
|
+
for (const edge of reverseEdges) {
|
|
1636
|
+
if (edgeKinds && !edgeKinds.includes(edge.kind)) continue;
|
|
1637
|
+
if (visited.has(edge.from)) continue;
|
|
1638
|
+
visited.add(edge.from);
|
|
1639
|
+
const node = graph.getNode(edge.from);
|
|
1640
|
+
if (node) {
|
|
1641
|
+
const depth = current.depth + 1;
|
|
1642
|
+
importers.push({
|
|
1643
|
+
symbolId: node.symbolId,
|
|
1644
|
+
name: node.name,
|
|
1645
|
+
kind: node.kind,
|
|
1646
|
+
file: node.symbolId.split("::")[0] ?? "",
|
|
1647
|
+
edgeKind: edge.kind,
|
|
1648
|
+
depth
|
|
1649
|
+
});
|
|
1650
|
+
queue.push({ id: edge.from, depth });
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
importers.sort((a, b) => a.depth - b.depth);
|
|
1656
|
+
const filtered = filterByIntent(importers, parsed.data.intent);
|
|
1657
|
+
const payload = masking.mask(JSON.stringify(wrapResponse({
|
|
1658
|
+
symbolId,
|
|
1659
|
+
importerCount: filtered.length,
|
|
1660
|
+
importers: filtered
|
|
1661
|
+
})));
|
|
1662
|
+
const content = [];
|
|
1663
|
+
if (staleness) {
|
|
1664
|
+
const warning = staleness.check(storage.listIndexedFiles());
|
|
1665
|
+
if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
|
|
1666
|
+
}
|
|
1667
|
+
content.push({ type: "text", text: payload });
|
|
1668
|
+
return { content };
|
|
1669
|
+
} catch (err) {
|
|
1670
|
+
return {
|
|
1671
|
+
content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
// src/adapters/mcp/get-class-hierarchy.ts
|
|
1678
|
+
import { z as z12 } from "zod";
|
|
1679
|
+
var InputSchema12 = z12.object({
|
|
1680
|
+
symbolId: z12.string().min(1).optional(),
|
|
1681
|
+
direction: z12.enum(["ancestors", "descendants", "both"]).optional().default("both")
|
|
1682
|
+
});
|
|
1683
|
+
function handleGetClassHierarchy(storage, masking, staleness, ctxoRoot = ".ctxo") {
|
|
1684
|
+
const getGraph = () => {
|
|
1685
|
+
const jsonGraph = buildGraphFromJsonIndex(ctxoRoot);
|
|
1686
|
+
if (jsonGraph.nodeCount > 0) return jsonGraph;
|
|
1687
|
+
return buildGraphFromStorage(storage);
|
|
1688
|
+
};
|
|
1689
|
+
return (args) => {
|
|
1690
|
+
try {
|
|
1691
|
+
const parsed = InputSchema12.safeParse(args);
|
|
1692
|
+
if (!parsed.success) {
|
|
1693
|
+
return {
|
|
1694
|
+
content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
const { symbolId, direction } = parsed.data;
|
|
1698
|
+
const graph = getGraph();
|
|
1699
|
+
if (symbolId) {
|
|
1700
|
+
return handleRooted(graph, symbolId, direction, masking, staleness, storage);
|
|
1701
|
+
}
|
|
1702
|
+
return handleFull(graph, masking, staleness, storage);
|
|
1703
|
+
} catch (err) {
|
|
1704
|
+
return {
|
|
1705
|
+
content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
};
|
|
1709
|
+
function handleRooted(graph, symbolId, direction, maskingPort, stalenessCheck, storagePort) {
|
|
1710
|
+
if (!graph.hasNode(symbolId)) {
|
|
1711
|
+
return {
|
|
1712
|
+
content: [{ type: "text", text: JSON.stringify({ found: false, hint: 'Symbol not found. Run "ctxo index" to build the codebase index.' }) }]
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
const ancestors = [];
|
|
1716
|
+
const descendants = [];
|
|
1717
|
+
const isHierarchyEdge = (kind) => kind === "extends" || kind === "implements";
|
|
1718
|
+
if (direction === "ancestors" || direction === "both") {
|
|
1719
|
+
const visited = /* @__PURE__ */ new Set([symbolId]);
|
|
1720
|
+
const queue = [{ id: symbolId, depth: 0 }];
|
|
1721
|
+
while (queue.length > 0) {
|
|
1722
|
+
const current = queue.shift();
|
|
1723
|
+
for (const edge of graph.getForwardEdges(current.id)) {
|
|
1724
|
+
if (!isHierarchyEdge(edge.kind)) continue;
|
|
1725
|
+
if (visited.has(edge.to)) continue;
|
|
1726
|
+
visited.add(edge.to);
|
|
1727
|
+
const node = graph.getNode(edge.to);
|
|
1728
|
+
if (node) {
|
|
1729
|
+
const depth = current.depth + 1;
|
|
1730
|
+
ancestors.push({
|
|
1731
|
+
symbolId: node.symbolId,
|
|
1732
|
+
name: node.name,
|
|
1733
|
+
kind: node.kind,
|
|
1734
|
+
file: node.symbolId.split("::")[0] ?? "",
|
|
1735
|
+
edgeKind: edge.kind,
|
|
1736
|
+
depth
|
|
1737
|
+
});
|
|
1738
|
+
queue.push({ id: edge.to, depth });
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
if (direction === "descendants" || direction === "both") {
|
|
1744
|
+
const visited = /* @__PURE__ */ new Set([symbolId]);
|
|
1745
|
+
const queue = [{ id: symbolId, depth: 0 }];
|
|
1746
|
+
while (queue.length > 0) {
|
|
1747
|
+
const current = queue.shift();
|
|
1748
|
+
for (const edge of graph.getReverseEdges(current.id)) {
|
|
1749
|
+
if (!isHierarchyEdge(edge.kind)) continue;
|
|
1750
|
+
if (visited.has(edge.from)) continue;
|
|
1751
|
+
visited.add(edge.from);
|
|
1752
|
+
const node = graph.getNode(edge.from);
|
|
1753
|
+
if (node) {
|
|
1754
|
+
const depth = current.depth + 1;
|
|
1755
|
+
descendants.push({
|
|
1756
|
+
symbolId: node.symbolId,
|
|
1757
|
+
name: node.name,
|
|
1758
|
+
kind: node.kind,
|
|
1759
|
+
file: node.symbolId.split("::")[0] ?? "",
|
|
1760
|
+
edgeKind: edge.kind,
|
|
1761
|
+
depth
|
|
1762
|
+
});
|
|
1763
|
+
queue.push({ id: edge.from, depth });
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
const result = { symbolId };
|
|
1769
|
+
if (direction === "ancestors" || direction === "both") result.ancestors = ancestors;
|
|
1770
|
+
if (direction === "descendants" || direction === "both") result.descendants = descendants;
|
|
1771
|
+
const payload = maskingPort.mask(JSON.stringify(wrapResponse(result)));
|
|
1772
|
+
const content = [];
|
|
1773
|
+
if (stalenessCheck) {
|
|
1774
|
+
const warning = stalenessCheck.check(storagePort.listIndexedFiles());
|
|
1775
|
+
if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
|
|
1776
|
+
}
|
|
1777
|
+
content.push({ type: "text", text: payload });
|
|
1778
|
+
return { content };
|
|
1779
|
+
}
|
|
1780
|
+
function handleFull(graph, maskingPort, stalenessCheck, storagePort) {
|
|
1781
|
+
const isHierarchyEdge = (kind) => kind === "extends" || kind === "implements";
|
|
1782
|
+
const hierarchyEdges = graph.allEdges().filter((e) => isHierarchyEdge(e.kind));
|
|
1783
|
+
const involved = /* @__PURE__ */ new Set();
|
|
1784
|
+
for (const edge of hierarchyEdges) {
|
|
1785
|
+
involved.add(edge.from);
|
|
1786
|
+
involved.add(edge.to);
|
|
1787
|
+
}
|
|
1788
|
+
const sources = new Set(hierarchyEdges.map((e) => e.from));
|
|
1789
|
+
const roots = [...involved].filter((id) => !sources.has(id));
|
|
1790
|
+
const rootSet = roots.length > 0 ? roots : [...involved];
|
|
1791
|
+
function buildTree(rootId, visited2) {
|
|
1792
|
+
const node = graph.getNode(rootId);
|
|
1793
|
+
if (!node) return void 0;
|
|
1794
|
+
visited2.add(rootId);
|
|
1795
|
+
const children = [];
|
|
1796
|
+
for (const edge of graph.getReverseEdges(rootId)) {
|
|
1797
|
+
if (!isHierarchyEdge(edge.kind)) continue;
|
|
1798
|
+
if (visited2.has(edge.from)) continue;
|
|
1799
|
+
const child = buildTree(edge.from, visited2);
|
|
1800
|
+
if (child) {
|
|
1801
|
+
child.edgeKind = edge.kind;
|
|
1802
|
+
children.push(child);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
return {
|
|
1806
|
+
symbolId: node.symbolId,
|
|
1807
|
+
name: node.name,
|
|
1808
|
+
kind: node.kind,
|
|
1809
|
+
file: node.symbolId.split("::")[0] ?? "",
|
|
1810
|
+
children
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1814
|
+
const hierarchies = [];
|
|
1815
|
+
for (const rootId of rootSet) {
|
|
1816
|
+
if (visited.has(rootId)) continue;
|
|
1817
|
+
const tree = buildTree(rootId, visited);
|
|
1818
|
+
if (tree) hierarchies.push(tree);
|
|
1819
|
+
}
|
|
1820
|
+
const payload = maskingPort.mask(JSON.stringify(wrapResponse({
|
|
1821
|
+
hierarchies,
|
|
1822
|
+
totalClasses: involved.size,
|
|
1823
|
+
totalEdges: hierarchyEdges.length
|
|
1824
|
+
})));
|
|
1825
|
+
const content = [];
|
|
1826
|
+
if (stalenessCheck) {
|
|
1827
|
+
const warning = stalenessCheck.check(storagePort.listIndexedFiles());
|
|
1828
|
+
if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
|
|
1829
|
+
}
|
|
1830
|
+
content.push({ type: "text", text: payload });
|
|
1831
|
+
return { content };
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
// src/adapters/mcp/get-symbol-importance.ts
|
|
1836
|
+
import { z as z13 } from "zod";
|
|
1837
|
+
|
|
1838
|
+
// src/core/importance/pagerank-calculator.ts
|
|
1839
|
+
var DEFAULT_DAMPING = 0.85;
|
|
1840
|
+
var DEFAULT_MAX_ITERATIONS = 100;
|
|
1841
|
+
var DEFAULT_TOLERANCE = 1e-6;
|
|
1842
|
+
var DEFAULT_LIMIT = 25;
|
|
1843
|
+
var PageRankCalculator = class {
|
|
1844
|
+
calculate(graph, options = {}) {
|
|
1845
|
+
const damping = options.damping ?? DEFAULT_DAMPING;
|
|
1846
|
+
const maxIterations = options.maxIterations ?? DEFAULT_MAX_ITERATIONS;
|
|
1847
|
+
const tolerance = options.tolerance ?? DEFAULT_TOLERANCE;
|
|
1848
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
1849
|
+
const nodes = graph.allNodes();
|
|
1850
|
+
const n = nodes.length;
|
|
1851
|
+
if (n === 0) {
|
|
1852
|
+
return { rankings: [], totalSymbols: 0, iterations: 0, converged: true };
|
|
1853
|
+
}
|
|
1854
|
+
const scores = /* @__PURE__ */ new Map();
|
|
1855
|
+
const initialScore = 1 / n;
|
|
1856
|
+
for (const node of nodes) {
|
|
1857
|
+
scores.set(node.symbolId, initialScore);
|
|
1858
|
+
}
|
|
1859
|
+
const outDegree = /* @__PURE__ */ new Map();
|
|
1860
|
+
for (const node of nodes) {
|
|
1861
|
+
outDegree.set(node.symbolId, graph.getForwardEdges(node.symbolId).length);
|
|
1862
|
+
}
|
|
1863
|
+
let iterations = 0;
|
|
1864
|
+
let converged = false;
|
|
1865
|
+
while (iterations < maxIterations) {
|
|
1866
|
+
const newScores = /* @__PURE__ */ new Map();
|
|
1867
|
+
const base = (1 - damping) / n;
|
|
1868
|
+
let danglingSum = 0;
|
|
1869
|
+
for (const node of nodes) {
|
|
1870
|
+
if ((outDegree.get(node.symbolId) ?? 0) === 0) {
|
|
1871
|
+
danglingSum += scores.get(node.symbolId) ?? 0;
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
const danglingContrib = damping * danglingSum / n;
|
|
1875
|
+
for (const node of nodes) {
|
|
1876
|
+
let incomingScore = 0;
|
|
1877
|
+
const reverseEdges = graph.getReverseEdges(node.symbolId);
|
|
1878
|
+
const contributors = /* @__PURE__ */ new Set();
|
|
1879
|
+
for (const edge of reverseEdges) {
|
|
1880
|
+
if (contributors.has(edge.from)) continue;
|
|
1881
|
+
contributors.add(edge.from);
|
|
1882
|
+
const fromScore = scores.get(edge.from) ?? 0;
|
|
1883
|
+
const fromOut = outDegree.get(edge.from) ?? 1;
|
|
1884
|
+
incomingScore += fromScore / fromOut;
|
|
1885
|
+
}
|
|
1886
|
+
newScores.set(node.symbolId, base + damping * incomingScore + danglingContrib);
|
|
1887
|
+
}
|
|
1888
|
+
let maxDelta = 0;
|
|
1889
|
+
for (const node of nodes) {
|
|
1890
|
+
const delta = Math.abs((newScores.get(node.symbolId) ?? 0) - (scores.get(node.symbolId) ?? 0));
|
|
1891
|
+
if (delta > maxDelta) maxDelta = delta;
|
|
1892
|
+
}
|
|
1893
|
+
for (const [id, score] of newScores) {
|
|
1894
|
+
scores.set(id, score);
|
|
1895
|
+
}
|
|
1896
|
+
iterations++;
|
|
1897
|
+
if (maxDelta < tolerance) {
|
|
1898
|
+
converged = true;
|
|
1899
|
+
break;
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
const entries = nodes.map((node) => ({
|
|
1903
|
+
symbolId: node.symbolId,
|
|
1904
|
+
name: node.name,
|
|
1905
|
+
kind: node.kind,
|
|
1906
|
+
file: node.symbolId.split("::")[0] ?? "",
|
|
1907
|
+
score: Math.round((scores.get(node.symbolId) ?? 0) * 1e6) / 1e6,
|
|
1908
|
+
inDegree: graph.getReverseEdges(node.symbolId).length,
|
|
1909
|
+
outDegree: outDegree.get(node.symbolId) ?? 0
|
|
1910
|
+
}));
|
|
1911
|
+
entries.sort((a, b) => b.score - a.score);
|
|
1912
|
+
return {
|
|
1913
|
+
rankings: entries.slice(0, limit),
|
|
1914
|
+
totalSymbols: n,
|
|
1915
|
+
iterations,
|
|
1916
|
+
converged
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
};
|
|
1920
|
+
|
|
1921
|
+
// src/adapters/mcp/get-symbol-importance.ts
|
|
1922
|
+
var InputSchema13 = z13.object({
|
|
1923
|
+
limit: z13.number().int().min(1).max(200).optional().default(25),
|
|
1924
|
+
kind: z13.enum(["function", "class", "interface", "method", "variable", "type"]).optional(),
|
|
1925
|
+
filePattern: z13.string().optional(),
|
|
1926
|
+
damping: z13.number().min(0).max(1).optional().default(0.85)
|
|
1927
|
+
});
|
|
1928
|
+
function handleGetSymbolImportance(storage, masking, staleness, ctxoRoot = ".ctxo") {
|
|
1929
|
+
const calculator = new PageRankCalculator();
|
|
1930
|
+
const getGraph = () => {
|
|
1931
|
+
const jsonGraph = buildGraphFromJsonIndex(ctxoRoot);
|
|
1932
|
+
if (jsonGraph.nodeCount > 0) return jsonGraph;
|
|
1933
|
+
return buildGraphFromStorage(storage);
|
|
1934
|
+
};
|
|
1935
|
+
return (args) => {
|
|
1936
|
+
try {
|
|
1937
|
+
const parsed = InputSchema13.safeParse(args);
|
|
1938
|
+
if (!parsed.success) {
|
|
1939
|
+
return {
|
|
1940
|
+
content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
|
|
1941
|
+
};
|
|
1942
|
+
}
|
|
1943
|
+
const { limit, kind, filePattern, damping } = parsed.data;
|
|
1944
|
+
const graph = getGraph();
|
|
1945
|
+
const result = calculator.calculate(graph, { damping, limit: graph.nodeCount, maxIterations: 100 });
|
|
1946
|
+
let filtered = result.rankings;
|
|
1947
|
+
if (kind) {
|
|
1948
|
+
filtered = filtered.filter((e) => e.kind === kind);
|
|
1949
|
+
}
|
|
1950
|
+
if (filePattern) {
|
|
1951
|
+
const lowerPattern = filePattern.toLowerCase();
|
|
1952
|
+
filtered = filtered.filter((e) => e.file.toLowerCase().includes(lowerPattern));
|
|
1953
|
+
}
|
|
1954
|
+
filtered = filtered.slice(0, limit);
|
|
1955
|
+
const payload = masking.mask(JSON.stringify(wrapResponse({
|
|
1956
|
+
rankings: filtered,
|
|
1957
|
+
totalSymbols: result.totalSymbols,
|
|
1958
|
+
iterations: result.iterations,
|
|
1959
|
+
converged: result.converged,
|
|
1960
|
+
damping
|
|
1961
|
+
})));
|
|
1962
|
+
const content = [];
|
|
1963
|
+
if (staleness) {
|
|
1964
|
+
const warning = staleness.check(storage.listIndexedFiles());
|
|
1965
|
+
if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
|
|
1966
|
+
}
|
|
1967
|
+
content.push({ type: "text", text: payload });
|
|
1968
|
+
return { content };
|
|
1969
|
+
} catch (err) {
|
|
1970
|
+
return {
|
|
1971
|
+
content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
};
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
// src/adapters/mcp/get-pr-impact.ts
|
|
1978
|
+
import { z as z14 } from "zod";
|
|
1979
|
+
import { readFileSync, existsSync } from "fs";
|
|
1980
|
+
import { join } from "path";
|
|
1981
|
+
var InputSchema14 = z14.object({
|
|
1982
|
+
since: z14.string().optional().default("HEAD~1"),
|
|
1983
|
+
maxFiles: z14.number().int().min(1).optional().default(50),
|
|
1984
|
+
confidence: z14.enum(["confirmed", "likely", "potential"]).optional()
|
|
1985
|
+
});
|
|
1986
|
+
function loadCoChanges(ctxoRoot) {
|
|
1987
|
+
const path = join(ctxoRoot, "index", "co-changes.json");
|
|
1988
|
+
if (!existsSync(path)) return void 0;
|
|
1989
|
+
try {
|
|
1990
|
+
const matrix = JSON.parse(readFileSync(path, "utf-8"));
|
|
1991
|
+
return loadCoChangeMap(matrix);
|
|
1992
|
+
} catch {
|
|
1993
|
+
return void 0;
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
function handleGetPrImpact(storage, git, masking, staleness, ctxoRoot = ".ctxo") {
|
|
1997
|
+
const calculator = new BlastRadiusCalculator();
|
|
1998
|
+
const getGraph = () => {
|
|
1999
|
+
const jsonGraph = buildGraphFromJsonIndex(ctxoRoot);
|
|
2000
|
+
if (jsonGraph.nodeCount > 0) return jsonGraph;
|
|
2001
|
+
return buildGraphFromStorage(storage);
|
|
2002
|
+
};
|
|
2003
|
+
return async (args) => {
|
|
2004
|
+
try {
|
|
2005
|
+
const parsed = InputSchema14.safeParse(args);
|
|
2006
|
+
if (!parsed.success) {
|
|
2007
|
+
return {
|
|
2008
|
+
content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
2011
|
+
const { since, maxFiles, confidence: confFilter } = parsed.data;
|
|
2012
|
+
const changedPaths = await git.getChangedFiles(since);
|
|
2013
|
+
if (changedPaths.length === 0) {
|
|
2014
|
+
return {
|
|
2015
|
+
content: [{ type: "text", text: JSON.stringify({ since, changedFiles: 0, changedSymbols: 0, totalImpact: 0, riskLevel: "low", files: [], summary: { confirmedTotal: 0, likelyTotal: 0, potentialTotal: 0, highRiskSymbols: [] } }) }]
|
|
2016
|
+
};
|
|
2017
|
+
}
|
|
2018
|
+
const graph = getGraph();
|
|
2019
|
+
const coChangeMap = loadCoChanges(ctxoRoot);
|
|
2020
|
+
const allNodes = graph.allNodes();
|
|
2021
|
+
const nodesByFile = /* @__PURE__ */ new Map();
|
|
2022
|
+
for (const node of allNodes) {
|
|
2023
|
+
const file = node.symbolId.split("::")[0];
|
|
2024
|
+
let list = nodesByFile.get(file);
|
|
2025
|
+
if (!list) {
|
|
2026
|
+
list = [];
|
|
2027
|
+
nodesByFile.set(file, list);
|
|
2028
|
+
}
|
|
2029
|
+
list.push(node);
|
|
2030
|
+
}
|
|
2031
|
+
const limitedPaths = changedPaths.slice(0, maxFiles);
|
|
2032
|
+
let totalImpact = 0;
|
|
2033
|
+
let confirmedTotal = 0;
|
|
2034
|
+
let likelyTotal = 0;
|
|
2035
|
+
let potentialTotal = 0;
|
|
2036
|
+
const highRiskSymbols = [];
|
|
2037
|
+
const files = [];
|
|
2038
|
+
for (const filePath of limitedPaths) {
|
|
2039
|
+
const symbols = nodesByFile.get(filePath);
|
|
2040
|
+
if (!symbols || symbols.length === 0) continue;
|
|
2041
|
+
const fileSymbols = [];
|
|
2042
|
+
for (const sym of symbols) {
|
|
2043
|
+
const result = calculator.calculate(graph, sym.symbolId, coChangeMap);
|
|
2044
|
+
let impacted = result.impactedSymbols;
|
|
2045
|
+
if (confFilter) {
|
|
2046
|
+
impacted = impacted.filter((s) => s.confidence === confFilter);
|
|
2047
|
+
}
|
|
2048
|
+
const impactScore = impacted.length;
|
|
2049
|
+
const confirmed = impacted.filter((s) => s.confidence === "confirmed").length;
|
|
2050
|
+
const likely = impacted.filter((s) => s.confidence === "likely").length;
|
|
2051
|
+
const potential = impacted.filter((s) => s.confidence === "potential").length;
|
|
2052
|
+
totalImpact += impactScore;
|
|
2053
|
+
confirmedTotal += confirmed;
|
|
2054
|
+
likelyTotal += likely;
|
|
2055
|
+
potentialTotal += potential;
|
|
2056
|
+
if (result.overallRiskScore > 0.7) {
|
|
2057
|
+
highRiskSymbols.push(sym.symbolId);
|
|
2058
|
+
}
|
|
2059
|
+
const topImpacted = impacted.sort((a, b) => b.riskScore - a.riskScore).slice(0, 10);
|
|
2060
|
+
fileSymbols.push({
|
|
2061
|
+
symbolId: sym.symbolId,
|
|
2062
|
+
name: sym.name,
|
|
2063
|
+
kind: sym.kind,
|
|
2064
|
+
blast: {
|
|
2065
|
+
impactScore,
|
|
2066
|
+
confirmedCount: confirmed,
|
|
2067
|
+
likelyCount: likely,
|
|
2068
|
+
potentialCount: potential,
|
|
2069
|
+
riskScore: result.overallRiskScore,
|
|
2070
|
+
topImpacted
|
|
2071
|
+
}
|
|
2072
|
+
});
|
|
2073
|
+
}
|
|
2074
|
+
let coChangedWith;
|
|
2075
|
+
if (coChangeMap) {
|
|
2076
|
+
const entries = coChangeMap.get(filePath);
|
|
2077
|
+
if (entries && entries.length > 0) {
|
|
2078
|
+
coChangedWith = entries.sort((a, b) => b.frequency - a.frequency).slice(0, 5).map((e) => e.file1 === filePath ? e.file2 : e.file1);
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
files.push({
|
|
2082
|
+
file: filePath,
|
|
2083
|
+
symbols: fileSymbols,
|
|
2084
|
+
...coChangedWith ? { coChangedWith } : {}
|
|
2085
|
+
});
|
|
2086
|
+
}
|
|
2087
|
+
const maxRisk = files.reduce((max, f) => {
|
|
2088
|
+
const fileMax = f.symbols.reduce((m, s) => Math.max(m, s.blast.riskScore), 0);
|
|
2089
|
+
return Math.max(max, fileMax);
|
|
2090
|
+
}, 0);
|
|
2091
|
+
const riskLevel = maxRisk > 0.7 ? "high" : maxRisk > 0.3 ? "medium" : "low";
|
|
2092
|
+
const changedSymbols = files.reduce((sum, f) => sum + f.symbols.length, 0);
|
|
2093
|
+
const payload = masking.mask(JSON.stringify(wrapResponse({
|
|
2094
|
+
since,
|
|
2095
|
+
changedFiles: files.length,
|
|
2096
|
+
changedSymbols,
|
|
2097
|
+
totalImpact,
|
|
2098
|
+
riskLevel,
|
|
2099
|
+
files,
|
|
2100
|
+
summary: {
|
|
2101
|
+
confirmedTotal,
|
|
2102
|
+
likelyTotal,
|
|
2103
|
+
potentialTotal,
|
|
2104
|
+
highRiskSymbols: highRiskSymbols.slice(0, 10)
|
|
2105
|
+
}
|
|
2106
|
+
})));
|
|
2107
|
+
const content = [];
|
|
2108
|
+
if (staleness) {
|
|
2109
|
+
const warning = staleness.check(storage.listIndexedFiles());
|
|
2110
|
+
if (warning) content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
|
|
2111
|
+
}
|
|
2112
|
+
content.push({ type: "text", text: payload });
|
|
2113
|
+
return { content };
|
|
2114
|
+
} catch (err) {
|
|
2115
|
+
return {
|
|
2116
|
+
content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
2121
|
+
|
|
691
2122
|
// src/index.ts
|
|
2123
|
+
var log = createLogger("ctxo:mcp");
|
|
692
2124
|
function loadMaskingConfig(ctxoRoot) {
|
|
693
|
-
const jsonConfigPath =
|
|
694
|
-
if (
|
|
2125
|
+
const jsonConfigPath = join2(ctxoRoot, "masking.json");
|
|
2126
|
+
if (existsSync2(jsonConfigPath)) {
|
|
695
2127
|
try {
|
|
696
|
-
const raw =
|
|
2128
|
+
const raw = readFileSync2(jsonConfigPath, "utf-8");
|
|
697
2129
|
const patterns = JSON.parse(raw);
|
|
698
|
-
|
|
2130
|
+
log.info(`Loaded ${patterns.length} custom masking pattern(s)`);
|
|
699
2131
|
return MaskingPipeline.fromConfig(patterns);
|
|
700
2132
|
} catch (err) {
|
|
701
|
-
|
|
2133
|
+
log.error(`Failed to load masking config: ${err.message}`);
|
|
702
2134
|
}
|
|
703
2135
|
}
|
|
704
2136
|
return new MaskingPipeline();
|
|
@@ -706,7 +2138,7 @@ function loadMaskingConfig(ctxoRoot) {
|
|
|
706
2138
|
async function main() {
|
|
707
2139
|
const args = process.argv.slice(2);
|
|
708
2140
|
if (args.length > 0) {
|
|
709
|
-
const { CliRouter } = await import("./cli-router-
|
|
2141
|
+
const { CliRouter } = await import("./cli-router-NRUGPICL.js");
|
|
710
2142
|
const router = new CliRouter(process.cwd());
|
|
711
2143
|
await router.route(args);
|
|
712
2144
|
return;
|
|
@@ -717,40 +2149,51 @@ async function main() {
|
|
|
717
2149
|
const masking = loadMaskingConfig(ctxoRoot);
|
|
718
2150
|
const git = new SimpleGitAdapter(process.cwd());
|
|
719
2151
|
const server = new McpServer({ name: "ctxo", version: "0.1.0" });
|
|
720
|
-
const { StalenessDetector } = await import("./staleness-detector-
|
|
2152
|
+
const { StalenessDetector } = await import("./staleness-detector-VSDPTPX7.js");
|
|
721
2153
|
const staleness = new StalenessDetector(process.cwd(), ctxoRoot);
|
|
2154
|
+
const toolAnnotations = {
|
|
2155
|
+
readOnlyHint: true,
|
|
2156
|
+
destructiveHint: false,
|
|
2157
|
+
idempotentHint: true,
|
|
2158
|
+
openWorldHint: false
|
|
2159
|
+
};
|
|
722
2160
|
const logicSliceHandler = handleGetLogicSlice(storage, masking, staleness, ctxoRoot);
|
|
723
2161
|
const whyContextHandler = handleGetWhyContext(storage, git, masking, staleness, ctxoRoot);
|
|
724
2162
|
const changeIntelligenceHandler = handleGetChangeIntelligence(storage, git, masking, staleness, ctxoRoot);
|
|
725
2163
|
server.registerTool(
|
|
726
2164
|
"get_logic_slice",
|
|
727
2165
|
{
|
|
728
|
-
description:
|
|
2166
|
+
description: 'Retrieve a symbol and all its transitive dependencies as a Logic-Slice. Use this when you need to UNDERSTAND what a symbol depends on (downstream view). L1=signature only, L2=direct deps, L3=full closure, L4=with token budget. Use `intent` to filter dependencies by keyword (e.g., "core", "adapter"). All responses include `_meta` with item counts and truncation info. For impact analysis (what BREAKS if this changes), use get_blast_radius instead. For task-specific context, use get_context_for_task.',
|
|
729
2167
|
inputSchema: {
|
|
730
|
-
symbolId:
|
|
731
|
-
symbolIds:
|
|
732
|
-
level:
|
|
733
|
-
|
|
2168
|
+
symbolId: z15.string().optional().describe("Single symbol ID (format: file::name::kind)"),
|
|
2169
|
+
symbolIds: z15.array(z15.string()).optional().describe("Batch: array of symbol IDs"),
|
|
2170
|
+
level: z15.number().min(1).max(4).optional().default(3).describe("Detail level (L1=signature, L2=direct deps, L3=full closure, L4=with token budget)"),
|
|
2171
|
+
intent: z15.string().optional().describe('Filter dependencies by intent keywords (e.g., "core", "adapter")')
|
|
2172
|
+
},
|
|
2173
|
+
annotations: toolAnnotations
|
|
734
2174
|
},
|
|
735
2175
|
(args2) => logicSliceHandler(args2)
|
|
736
2176
|
);
|
|
737
2177
|
server.registerTool(
|
|
738
2178
|
"get_why_context",
|
|
739
2179
|
{
|
|
740
|
-
description: "Retrieve git commit intent
|
|
2180
|
+
description: "Retrieve git commit history intent and anti-pattern warnings (reverts, rollbacks) for a symbol. Use this when you need to understand WHY code was written this way or whether it has a history of problems. Pair with get_change_intelligence for complexity/churn scores.",
|
|
741
2181
|
inputSchema: {
|
|
742
|
-
symbolId:
|
|
743
|
-
|
|
2182
|
+
symbolId: z15.string().min(1).describe("The symbol ID (format: file::name::kind)"),
|
|
2183
|
+
maxCommits: z15.number().int().min(1).optional().describe("Limit commit history to N most recent commits")
|
|
2184
|
+
},
|
|
2185
|
+
annotations: toolAnnotations
|
|
744
2186
|
},
|
|
745
2187
|
(args2) => whyContextHandler(args2)
|
|
746
2188
|
);
|
|
747
2189
|
server.registerTool(
|
|
748
2190
|
"get_change_intelligence",
|
|
749
2191
|
{
|
|
750
|
-
description: "Retrieve complexity x churn composite score for a symbol",
|
|
2192
|
+
description: "Retrieve complexity x churn composite score for a symbol \u2014 identifies hotspots that are both complex and frequently changed. Use this to prioritize refactoring targets or assess risk before modifying code. For git history details, use get_why_context. For impact scope, use get_blast_radius.",
|
|
751
2193
|
inputSchema: {
|
|
752
|
-
symbolId:
|
|
753
|
-
}
|
|
2194
|
+
symbolId: z15.string().min(1).describe("The symbol ID (format: file::name::kind)")
|
|
2195
|
+
},
|
|
2196
|
+
annotations: toolAnnotations
|
|
754
2197
|
},
|
|
755
2198
|
(args2) => changeIntelligenceHandler(args2)
|
|
756
2199
|
);
|
|
@@ -758,10 +2201,13 @@ async function main() {
|
|
|
758
2201
|
server.registerTool(
|
|
759
2202
|
"get_blast_radius",
|
|
760
2203
|
{
|
|
761
|
-
description: "
|
|
2204
|
+
description: "BEFORE modifying any function or class, call this to understand impact. Returns all symbols that would break if the target changes, split into confirmed (direct importers), likely (co-changed), and potential (transitive) tiers with risk scores. Use `confidence` to filter by tier, `intent` to filter by keyword. Large results are auto-truncated with `_meta.hint` for drill-in. For what a symbol DEPENDS ON (downstream), use get_logic_slice instead. For full PR-level analysis, use get_pr_impact.",
|
|
762
2205
|
inputSchema: {
|
|
763
|
-
symbolId:
|
|
764
|
-
|
|
2206
|
+
symbolId: z15.string().min(1).describe("The symbol ID (format: file::name::kind)"),
|
|
2207
|
+
confidence: z15.enum(["confirmed", "likely", "potential"]).optional().describe("Filter by confidence tier"),
|
|
2208
|
+
intent: z15.string().optional().describe('Filter impacted symbols by intent keywords (e.g., "test", "adapter")')
|
|
2209
|
+
},
|
|
2210
|
+
annotations: toolAnnotations
|
|
765
2211
|
},
|
|
766
2212
|
(args2) => blastRadiusHandler(args2)
|
|
767
2213
|
);
|
|
@@ -769,18 +2215,149 @@ async function main() {
|
|
|
769
2215
|
server.registerTool(
|
|
770
2216
|
"get_architectural_overlay",
|
|
771
2217
|
{
|
|
772
|
-
description: "
|
|
2218
|
+
description: "Get the project architectural layer map \u2014 identifies which symbols belong to Domain, Infrastructure, and Adapter layers. Use this when onboarding to a new codebase or validating that a change respects layer boundaries. For symbol-level analysis, use get_logic_slice or get_blast_radius.",
|
|
773
2219
|
inputSchema: {
|
|
774
|
-
layer:
|
|
775
|
-
}
|
|
2220
|
+
layer: z15.string().optional().describe("Filter by specific layer name")
|
|
2221
|
+
},
|
|
2222
|
+
annotations: toolAnnotations
|
|
776
2223
|
},
|
|
777
2224
|
(args2) => overlayHandler(args2)
|
|
778
2225
|
);
|
|
2226
|
+
const deadCodeHandler = handleFindDeadCode(storage, masking, staleness, ctxoRoot);
|
|
2227
|
+
server.registerTool(
|
|
2228
|
+
"find_dead_code",
|
|
2229
|
+
{
|
|
2230
|
+
description: 'Find unreachable symbols and files that are never imported or called anywhere. Use this during cleanup, refactoring, or before deleting code to confirm it is truly unused. Use `intent` to filter results by keyword (e.g., "adapter", "function"). Large results are auto-truncated with `_meta`. For reverse dependency lookup of a specific symbol, use find_importers instead.',
|
|
2231
|
+
inputSchema: {
|
|
2232
|
+
includeTests: z15.boolean().optional().default(false).describe("Include test files in analysis (default: exclude)"),
|
|
2233
|
+
intent: z15.string().optional().describe('Filter dead code results by intent keywords (e.g., "adapter", "function")')
|
|
2234
|
+
},
|
|
2235
|
+
annotations: toolAnnotations
|
|
2236
|
+
},
|
|
2237
|
+
(args2) => deadCodeHandler(args2)
|
|
2238
|
+
);
|
|
2239
|
+
const contextForTaskHandler = handleGetContextForTask(storage, masking, staleness, ctxoRoot);
|
|
2240
|
+
server.registerTool(
|
|
2241
|
+
"get_context_for_task",
|
|
2242
|
+
{
|
|
2243
|
+
description: 'Get task-optimized context for a symbol based on what you are about to do. Specify taskType: "fix" (bug investigation \u2014 includes history + anti-patterns), "extend" (add feature \u2014 includes deps + blast radius), "refactor" (restructure \u2014 includes importers + complexity), or "understand" (learn \u2014 includes full slice + architecture). This is the BEST starting point when you know both the symbol and your intent.',
|
|
2244
|
+
inputSchema: {
|
|
2245
|
+
symbolId: z15.string().min(1).describe("The symbol ID (format: file::name::kind)"),
|
|
2246
|
+
taskType: z15.enum(["fix", "extend", "refactor", "understand"]).describe("Task type determines which context is most relevant"),
|
|
2247
|
+
tokenBudget: z15.number().optional().default(4e3).describe("Max tokens for context (default 4000)")
|
|
2248
|
+
},
|
|
2249
|
+
annotations: toolAnnotations
|
|
2250
|
+
},
|
|
2251
|
+
(args2) => contextForTaskHandler(args2)
|
|
2252
|
+
);
|
|
2253
|
+
const rankedContextHandler = handleGetRankedContext(storage, masking, staleness, ctxoRoot);
|
|
2254
|
+
server.registerTool(
|
|
2255
|
+
"get_ranked_context",
|
|
2256
|
+
{
|
|
2257
|
+
description: "Search and rank symbols by relevance to a natural language query, packed within a token budget. Uses BM25 text matching + PageRank importance scoring. Results include `_meta` with truncation info. Use this when you have a question or topic but do not know which specific symbol to look at. For exact symbol name search, use search_symbols instead.",
|
|
2258
|
+
inputSchema: {
|
|
2259
|
+
query: z15.string().min(1).describe("Search query (matches symbol names)"),
|
|
2260
|
+
tokenBudget: z15.number().optional().default(4e3).describe("Max tokens for results (default 4000)"),
|
|
2261
|
+
strategy: z15.enum(["combined", "dependency", "importance"]).optional().default("combined").describe("Ranking strategy")
|
|
2262
|
+
},
|
|
2263
|
+
annotations: toolAnnotations
|
|
2264
|
+
},
|
|
2265
|
+
(args2) => rankedContextHandler(args2)
|
|
2266
|
+
);
|
|
2267
|
+
const searchSymbolsHandler = handleSearchSymbols(storage, masking, staleness, ctxoRoot);
|
|
2268
|
+
server.registerTool(
|
|
2269
|
+
"search_symbols",
|
|
2270
|
+
{
|
|
2271
|
+
description: "Search symbols by exact name or regex pattern across the codebase index. Use this when you know (part of) the symbol name and need to find its ID for use with other tools. For semantic/relevance-based search, use get_ranked_context instead.",
|
|
2272
|
+
inputSchema: {
|
|
2273
|
+
pattern: z15.string().min(1).describe("Search pattern (substring or regex)"),
|
|
2274
|
+
kind: z15.enum(["function", "class", "interface", "method", "variable", "type"]).optional().describe("Filter by symbol kind"),
|
|
2275
|
+
filePattern: z15.string().optional().describe("Filter by file path substring"),
|
|
2276
|
+
limit: z15.number().int().min(1).max(100).optional().default(25).describe("Max results (default 25)")
|
|
2277
|
+
},
|
|
2278
|
+
annotations: toolAnnotations
|
|
2279
|
+
},
|
|
2280
|
+
(args2) => searchSymbolsHandler(args2)
|
|
2281
|
+
);
|
|
2282
|
+
const changedSymbolsHandler = handleGetChangedSymbols(storage, git, masking, staleness, ctxoRoot);
|
|
2283
|
+
server.registerTool(
|
|
2284
|
+
"get_changed_symbols",
|
|
2285
|
+
{
|
|
2286
|
+
description: "Get symbols in recently changed files based on git diff. Use this to see what was modified in recent commits. For full PR risk assessment (changed symbols + blast radius + co-changes), use get_pr_impact instead.",
|
|
2287
|
+
inputSchema: {
|
|
2288
|
+
since: z15.string().optional().default("HEAD~1").describe("Git ref to diff against (default HEAD~1)"),
|
|
2289
|
+
maxFiles: z15.number().int().min(1).optional().default(50).describe("Max changed files to process (default 50)")
|
|
2290
|
+
},
|
|
2291
|
+
annotations: toolAnnotations
|
|
2292
|
+
},
|
|
2293
|
+
(args2) => changedSymbolsHandler(args2)
|
|
2294
|
+
);
|
|
2295
|
+
const findImportersHandler = handleFindImporters(storage, masking, staleness, ctxoRoot);
|
|
2296
|
+
server.registerTool(
|
|
2297
|
+
"find_importers",
|
|
2298
|
+
{
|
|
2299
|
+
description: 'Find all symbols that import or depend on a given symbol (reverse dependency / "who uses this?"). Use this to check if a symbol is safe to modify or delete. Supports transitive traversal for deep impact chains. Use `intent` to filter by keyword (e.g., "test", "core"). For forward dependencies (what this symbol uses), use get_logic_slice. For aggregated impact with risk scores, use get_blast_radius.',
|
|
2300
|
+
inputSchema: {
|
|
2301
|
+
symbolId: z15.string().min(1).describe("The symbol ID (format: file::name::kind)"),
|
|
2302
|
+
edgeKinds: z15.array(z15.enum(["imports", "calls", "extends", "implements", "uses"])).optional().describe("Filter by edge kinds"),
|
|
2303
|
+
transitive: z15.boolean().optional().default(false).describe("Follow transitive reverse edges (default false)"),
|
|
2304
|
+
maxDepth: z15.number().int().min(1).max(10).optional().default(5).describe("Max BFS depth for transitive mode (default 5)"),
|
|
2305
|
+
intent: z15.string().optional().describe('Filter importers by intent keywords (e.g., "test", "core", "adapter")')
|
|
2306
|
+
},
|
|
2307
|
+
annotations: toolAnnotations
|
|
2308
|
+
},
|
|
2309
|
+
(args2) => findImportersHandler(args2)
|
|
2310
|
+
);
|
|
2311
|
+
const classHierarchyHandler = handleGetClassHierarchy(storage, masking, staleness, ctxoRoot);
|
|
2312
|
+
server.registerTool(
|
|
2313
|
+
"get_class_hierarchy",
|
|
2314
|
+
{
|
|
2315
|
+
description: "Get class inheritance hierarchy \u2014 ancestors (extends/implements chain) and descendants (subclasses). Use this when working with OOP code to understand type relationships before modifying a base class or interface. For dependency-based relationships (imports/calls), use get_logic_slice or find_importers.",
|
|
2316
|
+
inputSchema: {
|
|
2317
|
+
symbolId: z15.string().min(1).optional().describe("Root symbol ID (omit for full project hierarchy)"),
|
|
2318
|
+
direction: z15.enum(["ancestors", "descendants", "both"]).optional().default("both").describe("Traversal direction (default both)")
|
|
2319
|
+
},
|
|
2320
|
+
annotations: toolAnnotations
|
|
2321
|
+
},
|
|
2322
|
+
(args2) => classHierarchyHandler(args2)
|
|
2323
|
+
);
|
|
2324
|
+
const symbolImportanceHandler = handleGetSymbolImportance(storage, masking, staleness, ctxoRoot);
|
|
2325
|
+
server.registerTool(
|
|
2326
|
+
"get_symbol_importance",
|
|
2327
|
+
{
|
|
2328
|
+
description: "Rank symbols by structural importance using PageRank centrality on the dependency graph. Use this to identify the most critical symbols in the codebase \u2014 high-PageRank symbols are heavily depended upon and risky to change. For finding unused/unimportant code, use find_dead_code instead.",
|
|
2329
|
+
inputSchema: {
|
|
2330
|
+
limit: z15.number().int().min(1).max(200).optional().default(25).describe("Max results (default 25)"),
|
|
2331
|
+
kind: z15.enum(["function", "class", "interface", "method", "variable", "type"]).optional().describe("Filter by symbol kind"),
|
|
2332
|
+
filePattern: z15.string().optional().describe("Filter by file path substring"),
|
|
2333
|
+
damping: z15.number().min(0).max(1).optional().default(0.85).describe("PageRank damping factor (default 0.85)")
|
|
2334
|
+
},
|
|
2335
|
+
annotations: toolAnnotations
|
|
2336
|
+
},
|
|
2337
|
+
(args2) => symbolImportanceHandler(args2)
|
|
2338
|
+
);
|
|
2339
|
+
const prImpactHandler = handleGetPrImpact(storage, git, masking, staleness, ctxoRoot);
|
|
2340
|
+
server.registerTool(
|
|
2341
|
+
"get_pr_impact",
|
|
2342
|
+
{
|
|
2343
|
+
description: "Analyze the full impact of a PR or recent changes in a SINGLE call \u2014 combines changed symbols + blast radius + co-change history into one risk assessment. Results include `_meta` with truncation info. Use this FIRST when reviewing a PR or evaluating recent commits. For single-symbol analysis, use get_blast_radius instead.",
|
|
2344
|
+
inputSchema: {
|
|
2345
|
+
since: z15.string().optional().default("HEAD~1").describe("Git ref to diff against (default HEAD~1)"),
|
|
2346
|
+
maxFiles: z15.number().int().min(1).optional().default(50).describe("Max changed files to analyze"),
|
|
2347
|
+
confidence: z15.enum(["confirmed", "likely", "potential"]).optional().describe("Filter impacted symbols by confidence tier")
|
|
2348
|
+
},
|
|
2349
|
+
annotations: toolAnnotations
|
|
2350
|
+
},
|
|
2351
|
+
(args2) => prImpactHandler(args2)
|
|
2352
|
+
);
|
|
2353
|
+
server.resource("ctxo-status", "ctxo://status", async (uri) => ({
|
|
2354
|
+
contents: [{ uri: uri.href, text: "Ctxo MCP server is running." }]
|
|
2355
|
+
}));
|
|
779
2356
|
const transport = new StdioServerTransport();
|
|
780
2357
|
await server.connect(transport);
|
|
781
2358
|
}
|
|
782
2359
|
main().catch((err) => {
|
|
783
|
-
|
|
2360
|
+
log.error("Fatal: %s", err.message);
|
|
784
2361
|
process.exit(1);
|
|
785
2362
|
});
|
|
786
2363
|
//# sourceMappingURL=index.js.map
|