@ctxo/cli 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/atomic-write-AQNYJFAG.js +10 -0
- package/dist/{chunk-WXA2JJLH.js → chunk-2TVHKCCU.js} +3 -2
- package/dist/chunk-2TVHKCCU.js.map +1 -0
- package/dist/chunk-6EKERTJ7.js +80 -0
- package/dist/chunk-6EKERTJ7.js.map +1 -0
- package/dist/{chunk-4M4V5V36.js → chunk-HXEPS2XY.js} +466 -78
- package/dist/chunk-HXEPS2XY.js.map +1 -0
- package/dist/{cli-router-T3VZZJD6.js → cli-router-GZA44VOV.js} +836 -184
- package/dist/cli-router-GZA44VOV.js.map +1 -0
- package/dist/index.js +310 -55
- package/dist/index.js.map +1 -1
- package/dist/json-index-reader-XOEE6VMD.js +8 -0
- package/dist/json-index-reader-XOEE6VMD.js.map +1 -0
- package/package.json +5 -3
- package/pages/visualize-template.html +285 -19
- package/dist/chunk-4M4V5V36.js.map +0 -1
- package/dist/chunk-WXA2JJLH.js.map +0 -1
- package/dist/cli-router-T3VZZJD6.js.map +0 -1
- package/dist/json-index-reader-LME4EDA5.js +0 -8
- /package/dist/{json-index-reader-LME4EDA5.js.map → atomic-write-AQNYJFAG.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
ArchitecturalOverlay,
|
|
4
|
+
BoundaryViolationDetector,
|
|
4
5
|
DeadCodeDetector,
|
|
6
|
+
DriftDetector,
|
|
5
7
|
PageRankCalculator,
|
|
6
8
|
RevertDetector,
|
|
7
9
|
SessionRecorderAdapter,
|
|
8
10
|
SimpleGitAdapter,
|
|
9
11
|
SqliteStorageAdapter,
|
|
10
12
|
SymbolGraph,
|
|
11
|
-
createLogger,
|
|
12
13
|
detectWorkspace,
|
|
13
14
|
loadCoChangeMap,
|
|
14
15
|
loadConfig,
|
|
16
|
+
makeGlobMatcher,
|
|
17
|
+
maskingClusterLabelPatterns,
|
|
15
18
|
statsEnabled
|
|
16
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-HXEPS2XY.js";
|
|
20
|
+
import {
|
|
21
|
+
createLogger
|
|
22
|
+
} from "./chunk-6EKERTJ7.js";
|
|
17
23
|
import {
|
|
18
24
|
DetailLevelSchema,
|
|
19
25
|
JsonIndexReader
|
|
20
|
-
} from "./chunk-
|
|
26
|
+
} from "./chunk-2TVHKCCU.js";
|
|
21
27
|
|
|
22
28
|
// src/index.ts
|
|
23
29
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -301,7 +307,9 @@ var TRUNCATABLE_FIELDS = {
|
|
|
301
307
|
hierarchies: "Use get_class_hierarchy with a specific symbolId.",
|
|
302
308
|
scaffolding: "Review the scaffolding markers directly in the listed files.",
|
|
303
309
|
deadFiles: "Use find_dead_code with includeTests to adjust scope.",
|
|
304
|
-
files: "Use get_changed_symbols with a narrower since ref or smaller maxFiles."
|
|
310
|
+
files: "Use get_changed_symbols with a narrower since ref or smaller maxFiles.",
|
|
311
|
+
clusters: "Use get_architectural_overlay with mode=communities and layer filter for a focused view.",
|
|
312
|
+
clustersAffected: "Use get_blast_radius on individual changed symbols for per-cluster impact."
|
|
305
313
|
};
|
|
306
314
|
function getThreshold() {
|
|
307
315
|
const env = process.env["CTXO_RESPONSE_LIMIT"];
|
|
@@ -322,10 +330,14 @@ function findTruncatableArray(data) {
|
|
|
322
330
|
}
|
|
323
331
|
return best;
|
|
324
332
|
}
|
|
325
|
-
function buildMeta(base) {
|
|
326
|
-
|
|
333
|
+
function buildMeta(base, extras) {
|
|
334
|
+
const meta = workspaceMeta ? { ...base, workspace: workspaceMeta } : base;
|
|
335
|
+
if (extras?.snapshotStaleness) {
|
|
336
|
+
return { ...meta, snapshotStaleness: extras.snapshotStaleness };
|
|
337
|
+
}
|
|
338
|
+
return meta;
|
|
327
339
|
}
|
|
328
|
-
function wrapResponse(data) {
|
|
340
|
+
function wrapResponse(data, extras) {
|
|
329
341
|
const threshold = getThreshold();
|
|
330
342
|
const fullJson = JSON.stringify(data);
|
|
331
343
|
const totalBytes = Buffer.byteLength(fullJson, "utf-8");
|
|
@@ -334,23 +346,29 @@ function wrapResponse(data) {
|
|
|
334
346
|
if (totalBytes <= threshold) {
|
|
335
347
|
return {
|
|
336
348
|
...data,
|
|
337
|
-
_meta: buildMeta(
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
349
|
+
_meta: buildMeta(
|
|
350
|
+
{
|
|
351
|
+
totalItems,
|
|
352
|
+
returnedItems: totalItems,
|
|
353
|
+
truncated: false,
|
|
354
|
+
totalBytes
|
|
355
|
+
},
|
|
356
|
+
extras
|
|
357
|
+
)
|
|
343
358
|
};
|
|
344
359
|
}
|
|
345
360
|
if (!truncatable || truncatable.arr.length <= 1) {
|
|
346
361
|
return {
|
|
347
362
|
...data,
|
|
348
|
-
_meta: buildMeta(
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
363
|
+
_meta: buildMeta(
|
|
364
|
+
{
|
|
365
|
+
totalItems,
|
|
366
|
+
returnedItems: totalItems,
|
|
367
|
+
truncated: false,
|
|
368
|
+
totalBytes
|
|
369
|
+
},
|
|
370
|
+
extras
|
|
371
|
+
)
|
|
354
372
|
};
|
|
355
373
|
}
|
|
356
374
|
let lo = 1;
|
|
@@ -360,13 +378,16 @@ function wrapResponse(data) {
|
|
|
360
378
|
const trial = {
|
|
361
379
|
...data,
|
|
362
380
|
[truncatable.key]: truncatable.arr.slice(0, mid),
|
|
363
|
-
_meta: buildMeta(
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
381
|
+
_meta: buildMeta(
|
|
382
|
+
{
|
|
383
|
+
totalItems,
|
|
384
|
+
returnedItems: mid,
|
|
385
|
+
truncated: true,
|
|
386
|
+
totalBytes,
|
|
387
|
+
hint: TRUNCATABLE_FIELDS[truncatable.key]
|
|
388
|
+
},
|
|
389
|
+
extras
|
|
390
|
+
)
|
|
370
391
|
};
|
|
371
392
|
const size = Buffer.byteLength(JSON.stringify(trial), "utf-8");
|
|
372
393
|
if (size <= threshold) {
|
|
@@ -379,13 +400,16 @@ function wrapResponse(data) {
|
|
|
379
400
|
return {
|
|
380
401
|
...data,
|
|
381
402
|
[truncatable.key]: truncatable.arr.slice(0, lo),
|
|
382
|
-
_meta: buildMeta(
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
403
|
+
_meta: buildMeta(
|
|
404
|
+
{
|
|
405
|
+
totalItems,
|
|
406
|
+
returnedItems: lo,
|
|
407
|
+
truncated: true,
|
|
408
|
+
totalBytes,
|
|
409
|
+
hint
|
|
410
|
+
},
|
|
411
|
+
extras
|
|
412
|
+
)
|
|
389
413
|
};
|
|
390
414
|
}
|
|
391
415
|
|
|
@@ -517,13 +541,54 @@ function handleGetLogicSlice(storage, masking, staleness, ctxoRoot = ".ctxo") {
|
|
|
517
541
|
|
|
518
542
|
// src/adapters/mcp/get-why-context.ts
|
|
519
543
|
import { z as z2 } from "zod";
|
|
544
|
+
|
|
545
|
+
// src/core/overlay/snapshot-staleness.ts
|
|
546
|
+
async function buildSnapshotStaleness(storage, git) {
|
|
547
|
+
const snapshot = storage.readCommunities();
|
|
548
|
+
if (!snapshot || !snapshot.commitSha || snapshot.commitSha === "nocommit") return void 0;
|
|
549
|
+
const getHead = git.getHeadSha?.bind(git);
|
|
550
|
+
const countBetween = git.countCommitsBetween?.bind(git);
|
|
551
|
+
const head = getHead ? await getHead() : void 0;
|
|
552
|
+
if (!head) {
|
|
553
|
+
return { snapshotCommit: snapshot.commitSha };
|
|
554
|
+
}
|
|
555
|
+
if (head === snapshot.commitSha) {
|
|
556
|
+
return {
|
|
557
|
+
snapshotCommit: snapshot.commitSha,
|
|
558
|
+
currentHeadCommit: head,
|
|
559
|
+
commitsBehind: 0
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
const behind = countBetween ? await countBetween(snapshot.commitSha) : void 0;
|
|
563
|
+
const result = {
|
|
564
|
+
snapshotCommit: snapshot.commitSha,
|
|
565
|
+
currentHeadCommit: head,
|
|
566
|
+
...typeof behind === "number" ? { commitsBehind: behind } : {}
|
|
567
|
+
};
|
|
568
|
+
if (typeof behind === "number" && behind > 0) {
|
|
569
|
+
return {
|
|
570
|
+
...result,
|
|
571
|
+
hint: `Community snapshot is ${behind} commit${behind === 1 ? "" : "s"} behind HEAD. Run \`ctxo index\` to refresh cluster / drift / boundary data.`
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
if (behind === void 0) {
|
|
575
|
+
return {
|
|
576
|
+
...result,
|
|
577
|
+
hint: "Snapshot commit is not reachable from HEAD \u2014 it may have been rebased away. Run `ctxo index` to re-anchor."
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
return result;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// src/adapters/mcp/get-why-context.ts
|
|
520
584
|
var InputSchema2 = z2.object({
|
|
521
585
|
symbolId: z2.string().min(1),
|
|
522
586
|
maxCommits: z2.number().int().min(1).optional()
|
|
523
587
|
});
|
|
524
|
-
function handleGetWhyContext(storage, git, masking, staleness, ctxoRoot = ".ctxo") {
|
|
588
|
+
function handleGetWhyContext(storage, git, masking, staleness, ctxoRoot = ".ctxo", clusterLabelMasker) {
|
|
525
589
|
const revertDetector = new RevertDetector();
|
|
526
590
|
const indexReader = new JsonIndexReader(ctxoRoot);
|
|
591
|
+
const driftDetector = new DriftDetector();
|
|
527
592
|
return async (args) => {
|
|
528
593
|
try {
|
|
529
594
|
const parsed = InputSchema2.safeParse(args);
|
|
@@ -565,6 +630,26 @@ function handleGetWhyContext(storage, git, masking, staleness, ctxoRoot = ".ctxo
|
|
|
565
630
|
if (antiPatterns.length > 0) {
|
|
566
631
|
responsePayload.warningBadge = "\u26A0 Anti-pattern detected";
|
|
567
632
|
}
|
|
633
|
+
const rawSnapshot = storage.readCommunities();
|
|
634
|
+
const currentSnapshot = rawSnapshot && clusterLabelMasker?.isEnabled() ? clusterLabelMasker.maskSnapshot(rawSnapshot) : rawSnapshot;
|
|
635
|
+
if (currentSnapshot) {
|
|
636
|
+
const history = storage.listCommunityHistory();
|
|
637
|
+
const drift = driftDetector.detect(currentSnapshot, history);
|
|
638
|
+
const relevant = drift.events.filter((event) => event.symbolId === symbolId);
|
|
639
|
+
if (relevant.length > 0 || drift.hint) {
|
|
640
|
+
responsePayload.driftSignals = {
|
|
641
|
+
events: relevant,
|
|
642
|
+
confidence: drift.confidence,
|
|
643
|
+
snapshotsAvailable: drift.snapshotsAvailable,
|
|
644
|
+
stabilitySummary: drift.stability,
|
|
645
|
+
...drift.hint ? { hint: drift.hint } : {}
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
const stalenessMeta = await buildSnapshotStaleness(storage, git);
|
|
650
|
+
if (stalenessMeta) {
|
|
651
|
+
responsePayload._meta = { snapshotStaleness: stalenessMeta };
|
|
652
|
+
}
|
|
568
653
|
const payload = masking.mask(JSON.stringify(responsePayload));
|
|
569
654
|
const content = [];
|
|
570
655
|
if (staleness) {
|
|
@@ -763,15 +848,35 @@ var BlastRadiusCalculator = class {
|
|
|
763
848
|
};
|
|
764
849
|
|
|
765
850
|
// src/adapters/mcp/get-blast-radius.ts
|
|
851
|
+
function computeClusterBreakdown(snapshot, rootSymbolId, impactedSymbolIds) {
|
|
852
|
+
if (impactedSymbolIds.length === 0) return void 0;
|
|
853
|
+
const assignment = /* @__PURE__ */ new Map();
|
|
854
|
+
for (const entry of snapshot.communities) {
|
|
855
|
+
assignment.set(entry.symbolId, { id: entry.communityId, label: entry.communityLabel });
|
|
856
|
+
}
|
|
857
|
+
const rootCluster = assignment.get(rootSymbolId);
|
|
858
|
+
const byCluster = {};
|
|
859
|
+
let crossClusterCount = 0;
|
|
860
|
+
for (const symbolId of impactedSymbolIds) {
|
|
861
|
+
const cluster = assignment.get(symbolId);
|
|
862
|
+
if (!cluster) continue;
|
|
863
|
+
const label = cluster.label || `community-${cluster.id}`;
|
|
864
|
+
byCluster[label] = (byCluster[label] ?? 0) + 1;
|
|
865
|
+
if (rootCluster && cluster.id !== rootCluster.id) crossClusterCount++;
|
|
866
|
+
}
|
|
867
|
+
const clusterCount = Object.keys(byCluster).length;
|
|
868
|
+
const multiClusterHint = clusterCount >= 3 ? `Change impacts ${clusterCount} clusters \u2014 multi-team review recommended.` : void 0;
|
|
869
|
+
return { byCluster, crossClusterCount, multiClusterHint };
|
|
870
|
+
}
|
|
766
871
|
var InputSchema4 = z4.object({
|
|
767
872
|
symbolId: z4.string().min(1),
|
|
768
873
|
confidence: z4.enum(["confirmed", "likely", "potential"]).optional(),
|
|
769
874
|
intent: z4.string().optional().describe('Filter results by intent keywords (e.g., "test", "adapter", "security")')
|
|
770
875
|
});
|
|
771
|
-
function handleGetBlastRadius(storage, masking, staleness, ctxoRoot = ".ctxo") {
|
|
876
|
+
function handleGetBlastRadius(storage, masking, staleness, ctxoRoot = ".ctxo", git, clusterLabelMasker) {
|
|
772
877
|
const calculator = new BlastRadiusCalculator();
|
|
773
878
|
const getGraph = () => getGraphAndFiles(ctxoRoot, storage);
|
|
774
|
-
return (args) => {
|
|
879
|
+
return async (args) => {
|
|
775
880
|
try {
|
|
776
881
|
const parsed = InputSchema4.safeParse(args);
|
|
777
882
|
if (!parsed.success) {
|
|
@@ -795,7 +900,10 @@ function handleGetBlastRadius(storage, masking, staleness, ctxoRoot = ".ctxo") {
|
|
|
795
900
|
const confirmedCount = symbols.filter((s) => s.confidence === "confirmed").length;
|
|
796
901
|
const likelyCount = symbols.filter((s) => s.confidence === "likely").length;
|
|
797
902
|
const potentialCount = symbols.filter((s) => s.confidence === "potential").length;
|
|
798
|
-
const
|
|
903
|
+
const rawSnapshot = storage.readCommunities();
|
|
904
|
+
const snapshot = rawSnapshot && clusterLabelMasker?.isEnabled() ? clusterLabelMasker.maskSnapshot(rawSnapshot) : rawSnapshot;
|
|
905
|
+
const clusterBreakdown = snapshot ? computeClusterBreakdown(snapshot, symbolId, symbols.map((s) => s.symbolId)) : void 0;
|
|
906
|
+
const body = {
|
|
799
907
|
symbolId,
|
|
800
908
|
impactScore: symbols.length,
|
|
801
909
|
directDependentsCount: result.directDependentsCount,
|
|
@@ -804,7 +912,17 @@ function handleGetBlastRadius(storage, masking, staleness, ctxoRoot = ".ctxo") {
|
|
|
804
912
|
potentialCount,
|
|
805
913
|
overallRiskScore: result.overallRiskScore,
|
|
806
914
|
impactedSymbols: symbols
|
|
807
|
-
}
|
|
915
|
+
};
|
|
916
|
+
if (clusterBreakdown) {
|
|
917
|
+
body.byCluster = clusterBreakdown.byCluster;
|
|
918
|
+
body.crossClusterEdges = clusterBreakdown.crossClusterCount;
|
|
919
|
+
if (clusterBreakdown.multiClusterHint) {
|
|
920
|
+
body.multiClusterHint = clusterBreakdown.multiClusterHint;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
const stalenessMeta = git ? await buildSnapshotStaleness(storage, git) : void 0;
|
|
924
|
+
const extras = stalenessMeta ? { snapshotStaleness: stalenessMeta } : void 0;
|
|
925
|
+
const payload = masking.mask(JSON.stringify(wrapResponse(body, extras)));
|
|
808
926
|
const content = [];
|
|
809
927
|
if (staleness) {
|
|
810
928
|
const warning = staleness.check(indexedFiles);
|
|
@@ -822,12 +940,57 @@ function handleGetBlastRadius(storage, masking, staleness, ctxoRoot = ".ctxo") {
|
|
|
822
940
|
|
|
823
941
|
// src/adapters/mcp/get-architectural-overlay.ts
|
|
824
942
|
import { z as z5 } from "zod";
|
|
943
|
+
var OVERLAY_MODES = ["regex", "communities", "both"];
|
|
825
944
|
var InputSchema5 = z5.object({
|
|
826
|
-
layer: z5.string().optional()
|
|
945
|
+
layer: z5.string().optional(),
|
|
946
|
+
mode: z5.enum(OVERLAY_MODES).optional()
|
|
827
947
|
});
|
|
828
|
-
function
|
|
948
|
+
function buildCommunityOverlay(snapshot, options = {}) {
|
|
949
|
+
const previewLimit = options.memberPreviewLimit ?? 15;
|
|
950
|
+
const byCluster = /* @__PURE__ */ new Map();
|
|
951
|
+
for (const entry of snapshot.communities) {
|
|
952
|
+
const list = byCluster.get(entry.communityId) ?? [];
|
|
953
|
+
list.push(entry.symbolId);
|
|
954
|
+
byCluster.set(entry.communityId, list);
|
|
955
|
+
}
|
|
956
|
+
const labelFor = /* @__PURE__ */ new Map();
|
|
957
|
+
for (const entry of snapshot.communities) {
|
|
958
|
+
if (!labelFor.has(entry.communityId)) {
|
|
959
|
+
labelFor.set(entry.communityId, entry.communityLabel);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
const godNodesByCommunity = /* @__PURE__ */ new Map();
|
|
963
|
+
for (const god of snapshot.godNodes) {
|
|
964
|
+
for (const communityId of god.bridgedCommunities) {
|
|
965
|
+
const set = godNodesByCommunity.get(communityId) ?? /* @__PURE__ */ new Set();
|
|
966
|
+
set.add(god.symbolId);
|
|
967
|
+
godNodesByCommunity.set(communityId, set);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
const clusters = [];
|
|
971
|
+
for (const [id, members] of byCluster) {
|
|
972
|
+
members.sort();
|
|
973
|
+
clusters.push({
|
|
974
|
+
id,
|
|
975
|
+
label: labelFor.get(id) ?? `community-${id}`,
|
|
976
|
+
memberCount: members.length,
|
|
977
|
+
members: members.slice(0, previewLimit),
|
|
978
|
+
godNodes: [...godNodesByCommunity.get(id) ?? []].sort()
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
clusters.sort((a, b) => b.memberCount - a.memberCount || a.id - b.id);
|
|
982
|
+
return {
|
|
983
|
+
clusters,
|
|
984
|
+
modularity: snapshot.modularity,
|
|
985
|
+
edgeQuality: snapshot.edgeQuality,
|
|
986
|
+
crossClusterEdges: snapshot.crossClusterEdges,
|
|
987
|
+
computedAt: snapshot.computedAt,
|
|
988
|
+
commitSha: snapshot.commitSha
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
function handleGetArchitecturalOverlay(storage, masking, staleness, git, clusterLabelMasker) {
|
|
829
992
|
const overlay = new ArchitecturalOverlay();
|
|
830
|
-
return (args) => {
|
|
993
|
+
return async (args) => {
|
|
831
994
|
try {
|
|
832
995
|
const parsed = InputSchema5.safeParse(args);
|
|
833
996
|
if (!parsed.success) {
|
|
@@ -837,6 +1000,7 @@ function handleGetArchitecturalOverlay(storage, masking, staleness) {
|
|
|
837
1000
|
}
|
|
838
1001
|
const files = storage.listIndexedFiles();
|
|
839
1002
|
const result = overlay.classify(files);
|
|
1003
|
+
const mode = parsed.data.mode ?? "both";
|
|
840
1004
|
const buildContent = (payloadStr) => {
|
|
841
1005
|
const content = [];
|
|
842
1006
|
if (staleness) {
|
|
@@ -846,12 +1010,25 @@ function handleGetArchitecturalOverlay(storage, masking, staleness) {
|
|
|
846
1010
|
content.push({ type: "text", text: payloadStr });
|
|
847
1011
|
return content;
|
|
848
1012
|
};
|
|
1013
|
+
const stalenessMeta = git ? await buildSnapshotStaleness(storage, git) : void 0;
|
|
1014
|
+
const extras = stalenessMeta ? { snapshotStaleness: stalenessMeta } : void 0;
|
|
849
1015
|
if (parsed.data.layer) {
|
|
850
1016
|
const filtered = result.layers[parsed.data.layer];
|
|
851
|
-
const payload2 = masking.mask(
|
|
1017
|
+
const payload2 = masking.mask(
|
|
1018
|
+
JSON.stringify(wrapResponse({ layer: parsed.data.layer, files: filtered ?? [] }, extras))
|
|
1019
|
+
);
|
|
852
1020
|
return { content: buildContent(payload2) };
|
|
853
1021
|
}
|
|
854
|
-
const
|
|
1022
|
+
const rawSnapshot = mode === "regex" ? void 0 : storage.readCommunities();
|
|
1023
|
+
const snapshot = rawSnapshot && clusterLabelMasker?.isEnabled() ? clusterLabelMasker.maskSnapshot(rawSnapshot) : rawSnapshot;
|
|
1024
|
+
const community = snapshot ? buildCommunityOverlay(snapshot) : void 0;
|
|
1025
|
+
const body = {};
|
|
1026
|
+
if (mode !== "communities") body.layers = result.layers;
|
|
1027
|
+
if (community) body.communities = community;
|
|
1028
|
+
if (mode === "communities" && !community) {
|
|
1029
|
+
body.hint = "No community snapshot available. Run `ctxo index` to generate one.";
|
|
1030
|
+
}
|
|
1031
|
+
const payload = masking.mask(JSON.stringify(wrapResponse(body, extras)));
|
|
855
1032
|
return { content: buildContent(payload) };
|
|
856
1033
|
} catch (err) {
|
|
857
1034
|
return {
|
|
@@ -2294,7 +2471,20 @@ function loadCoChanges(ctxoRoot) {
|
|
|
2294
2471
|
return void 0;
|
|
2295
2472
|
}
|
|
2296
2473
|
}
|
|
2297
|
-
function
|
|
2474
|
+
function computeClustersAffected(snapshot, changedSymbolIds) {
|
|
2475
|
+
const totals = /* @__PURE__ */ new Map();
|
|
2476
|
+
for (const entry of snapshot.communities) {
|
|
2477
|
+
if (!changedSymbolIds.has(entry.symbolId)) continue;
|
|
2478
|
+
const existing = totals.get(entry.communityId);
|
|
2479
|
+
if (existing) {
|
|
2480
|
+
existing.count += 1;
|
|
2481
|
+
} else {
|
|
2482
|
+
totals.set(entry.communityId, { label: entry.communityLabel, count: 1 });
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
return [...totals.entries()].map(([id, { label, count }]) => ({ id, label, symbolCount: count })).sort((a, b) => b.symbolCount - a.symbolCount || a.id - b.id);
|
|
2486
|
+
}
|
|
2487
|
+
function handleGetPrImpact(storage, git, masking, staleness, ctxoRoot = ".ctxo", clusterLabelMasker) {
|
|
2298
2488
|
const calculator = new BlastRadiusCalculator();
|
|
2299
2489
|
const getGraph = () => getGraphAndFiles(ctxoRoot, storage);
|
|
2300
2490
|
return async (args) => {
|
|
@@ -2387,7 +2577,30 @@ function handleGetPrImpact(storage, git, masking, staleness, ctxoRoot = ".ctxo")
|
|
|
2387
2577
|
}, 0);
|
|
2388
2578
|
const riskLevel = maxRisk > 0.7 ? "high" : maxRisk > 0.3 ? "medium" : "low";
|
|
2389
2579
|
const changedSymbols = files.reduce((sum, f) => sum + f.symbols.length, 0);
|
|
2390
|
-
const
|
|
2580
|
+
const rawSnapshot = storage.readCommunities();
|
|
2581
|
+
const snapshot = rawSnapshot && clusterLabelMasker?.isEnabled() ? clusterLabelMasker.maskSnapshot(rawSnapshot) : rawSnapshot;
|
|
2582
|
+
const history = snapshot ? storage.listCommunityHistory() : [];
|
|
2583
|
+
let boundarySection;
|
|
2584
|
+
let clustersAffected;
|
|
2585
|
+
if (snapshot) {
|
|
2586
|
+
const detector = new BoundaryViolationDetector();
|
|
2587
|
+
const violationResult = detector.detect(graph, snapshot, history);
|
|
2588
|
+
const changedSymbolIds = /* @__PURE__ */ new Set();
|
|
2589
|
+
for (const file of files) {
|
|
2590
|
+
for (const sym of file.symbols) changedSymbolIds.add(sym.symbolId);
|
|
2591
|
+
}
|
|
2592
|
+
const relevant = violationResult.violations.filter(
|
|
2593
|
+
(v) => changedSymbolIds.has(v.from.symbolId) || changedSymbolIds.has(v.to.symbolId)
|
|
2594
|
+
);
|
|
2595
|
+
boundarySection = {
|
|
2596
|
+
violations: relevant,
|
|
2597
|
+
confidence: violationResult.confidence,
|
|
2598
|
+
snapshotsAvailable: violationResult.snapshotsAvailable,
|
|
2599
|
+
...violationResult.hint ? { hint: violationResult.hint } : {}
|
|
2600
|
+
};
|
|
2601
|
+
clustersAffected = computeClustersAffected(snapshot, changedSymbolIds);
|
|
2602
|
+
}
|
|
2603
|
+
const body = {
|
|
2391
2604
|
since,
|
|
2392
2605
|
changedFiles: files.length,
|
|
2393
2606
|
changedSymbols,
|
|
@@ -2400,7 +2613,12 @@ function handleGetPrImpact(storage, git, masking, staleness, ctxoRoot = ".ctxo")
|
|
|
2400
2613
|
potentialTotal,
|
|
2401
2614
|
highRiskSymbols: highRiskSymbols.slice(0, 10)
|
|
2402
2615
|
}
|
|
2403
|
-
}
|
|
2616
|
+
};
|
|
2617
|
+
if (boundarySection) body.boundaryViolations = boundarySection;
|
|
2618
|
+
if (clustersAffected && clustersAffected.length > 0) body.clustersAffected = clustersAffected;
|
|
2619
|
+
const stalenessMeta = await buildSnapshotStaleness(storage, git);
|
|
2620
|
+
const extras = stalenessMeta ? { snapshotStaleness: stalenessMeta } : void 0;
|
|
2621
|
+
const payload = masking.mask(JSON.stringify(wrapResponse(body, extras)));
|
|
2404
2622
|
const content = [];
|
|
2405
2623
|
if (staleness) {
|
|
2406
2624
|
const warning = staleness.check(indexedFiles);
|
|
@@ -2462,6 +2680,40 @@ function recordEvent(toolName, args, result, latencyMs, recorder) {
|
|
|
2462
2680
|
}
|
|
2463
2681
|
}
|
|
2464
2682
|
|
|
2683
|
+
// src/core/overlay/cluster-label-masker.ts
|
|
2684
|
+
var ClusterLabelMasker = class {
|
|
2685
|
+
matcher;
|
|
2686
|
+
enabled;
|
|
2687
|
+
labelCache = /* @__PURE__ */ new Map();
|
|
2688
|
+
constructor(patterns) {
|
|
2689
|
+
this.enabled = patterns.length > 0;
|
|
2690
|
+
this.matcher = this.enabled ? makeGlobMatcher([...patterns]) : () => false;
|
|
2691
|
+
}
|
|
2692
|
+
isEnabled() {
|
|
2693
|
+
return this.enabled;
|
|
2694
|
+
}
|
|
2695
|
+
mask(label) {
|
|
2696
|
+
if (!this.enabled) return label;
|
|
2697
|
+
if (!this.matcher(label)) return label;
|
|
2698
|
+
const cached = this.labelCache.get(label);
|
|
2699
|
+
if (cached) return cached;
|
|
2700
|
+
const masked = `[masked-cluster-${this.labelCache.size + 1}]`;
|
|
2701
|
+
this.labelCache.set(label, masked);
|
|
2702
|
+
return masked;
|
|
2703
|
+
}
|
|
2704
|
+
maskSnapshot(snapshot) {
|
|
2705
|
+
if (!this.enabled) return snapshot;
|
|
2706
|
+
const uniqueLabels = [...new Set(snapshot.communities.map((c) => c.communityLabel))].sort();
|
|
2707
|
+
for (const label of uniqueLabels) this.mask(label);
|
|
2708
|
+
const maskedCommunities = snapshot.communities.map((entry) => ({
|
|
2709
|
+
...entry,
|
|
2710
|
+
communityLabel: this.mask(entry.communityLabel)
|
|
2711
|
+
}));
|
|
2712
|
+
const maskedGodNodes = snapshot.godNodes.map((g) => ({ ...g }));
|
|
2713
|
+
return { ...snapshot, communities: maskedCommunities, godNodes: maskedGodNodes };
|
|
2714
|
+
}
|
|
2715
|
+
};
|
|
2716
|
+
|
|
2465
2717
|
// src/index.ts
|
|
2466
2718
|
var log4 = createLogger("ctxo:mcp");
|
|
2467
2719
|
function loadMaskingConfig(ctxoRoot) {
|
|
@@ -2495,7 +2747,7 @@ async function main() {
|
|
|
2495
2747
|
const args = process.argv.slice(2);
|
|
2496
2748
|
const httpPortStr = process.env.CTXO_HTTP_PORT || (args.includes("--http") ? args[args.indexOf("--port") + 1] || "3001" : null);
|
|
2497
2749
|
if (!httpPortStr && args.length > 0) {
|
|
2498
|
-
const { CliRouter } = await import("./cli-router-
|
|
2750
|
+
const { CliRouter } = await import("./cli-router-GZA44VOV.js");
|
|
2499
2751
|
const router = new CliRouter(process.cwd());
|
|
2500
2752
|
await router.route(args);
|
|
2501
2753
|
return;
|
|
@@ -2503,19 +2755,22 @@ async function main() {
|
|
|
2503
2755
|
const ctxoRoot = ".ctxo";
|
|
2504
2756
|
const workspace = detectWorkspace(process.cwd());
|
|
2505
2757
|
setWorkspaceMeta({ root: workspace.root });
|
|
2506
|
-
const storage = new SqliteStorageAdapter(ctxoRoot);
|
|
2758
|
+
const storage = new SqliteStorageAdapter(ctxoRoot, { allowProductionPath: true });
|
|
2507
2759
|
await storage.init();
|
|
2508
2760
|
const masking = loadMaskingConfig(ctxoRoot);
|
|
2761
|
+
const clusterLabelMasker = new ClusterLabelMasker(
|
|
2762
|
+
maskingClusterLabelPatterns(loadConfig(ctxoRoot).config)
|
|
2763
|
+
);
|
|
2509
2764
|
const git = new SimpleGitAdapter(process.cwd());
|
|
2510
2765
|
const recorder = readStatsEnabled(ctxoRoot) ? new SessionRecorderAdapter(storage.getDb(), () => storage.persist()) : null;
|
|
2511
2766
|
const server = new McpServer({ name: "ctxo", version: PKG_VERSION });
|
|
2512
2767
|
const { StalenessDetector } = await import("./staleness-detector-4HJYBQ7Z.js");
|
|
2513
2768
|
const staleness = new StalenessDetector(process.cwd(), ctxoRoot);
|
|
2514
|
-
registerTools(server, storage, masking, git, staleness, recorder, ctxoRoot);
|
|
2769
|
+
registerTools(server, storage, masking, git, staleness, recorder, ctxoRoot, clusterLabelMasker);
|
|
2515
2770
|
if (httpPortStr) {
|
|
2516
2771
|
await startHttpTransport(async () => {
|
|
2517
2772
|
const s = new McpServer({ name: "ctxo", version: PKG_VERSION });
|
|
2518
|
-
registerTools(s, storage, masking, git, staleness, recorder, ctxoRoot);
|
|
2773
|
+
registerTools(s, storage, masking, git, staleness, recorder, ctxoRoot, clusterLabelMasker);
|
|
2519
2774
|
return s;
|
|
2520
2775
|
}, parseInt(httpPortStr, 10));
|
|
2521
2776
|
} else {
|
|
@@ -2523,7 +2778,7 @@ async function main() {
|
|
|
2523
2778
|
await server.connect(transport);
|
|
2524
2779
|
}
|
|
2525
2780
|
}
|
|
2526
|
-
function registerTools(server, storage, masking, git, staleness, recorder, ctxoRoot = ".ctxo") {
|
|
2781
|
+
function registerTools(server, storage, masking, git, staleness, recorder, ctxoRoot = ".ctxo", clusterLabelMasker) {
|
|
2527
2782
|
const toolAnnotations = {
|
|
2528
2783
|
readOnlyHint: true,
|
|
2529
2784
|
destructiveHint: false,
|
|
@@ -2531,7 +2786,7 @@ function registerTools(server, storage, masking, git, staleness, recorder, ctxoR
|
|
|
2531
2786
|
openWorldHint: false
|
|
2532
2787
|
};
|
|
2533
2788
|
const logicSliceHandler = withRecording("get_logic_slice", handleGetLogicSlice(storage, masking, staleness, ctxoRoot), recorder);
|
|
2534
|
-
const whyContextHandler = withRecording("get_why_context", handleGetWhyContext(storage, git, masking, staleness, ctxoRoot), recorder);
|
|
2789
|
+
const whyContextHandler = withRecording("get_why_context", handleGetWhyContext(storage, git, masking, staleness, ctxoRoot, clusterLabelMasker), recorder);
|
|
2535
2790
|
const changeIntelligenceHandler = withRecording("get_change_intelligence", handleGetChangeIntelligence(storage, git, masking, staleness, ctxoRoot), recorder);
|
|
2536
2791
|
server.registerTool(
|
|
2537
2792
|
"get_logic_slice",
|
|
@@ -2570,7 +2825,7 @@ function registerTools(server, storage, masking, git, staleness, recorder, ctxoR
|
|
|
2570
2825
|
},
|
|
2571
2826
|
(args) => changeIntelligenceHandler(args)
|
|
2572
2827
|
);
|
|
2573
|
-
const blastRadiusHandler = withRecording("get_blast_radius", handleGetBlastRadius(storage, masking, staleness, ctxoRoot), recorder);
|
|
2828
|
+
const blastRadiusHandler = withRecording("get_blast_radius", handleGetBlastRadius(storage, masking, staleness, ctxoRoot, git, clusterLabelMasker), recorder);
|
|
2574
2829
|
server.registerTool(
|
|
2575
2830
|
"get_blast_radius",
|
|
2576
2831
|
{
|
|
@@ -2584,7 +2839,7 @@ function registerTools(server, storage, masking, git, staleness, recorder, ctxoR
|
|
|
2584
2839
|
},
|
|
2585
2840
|
(args) => blastRadiusHandler(args)
|
|
2586
2841
|
);
|
|
2587
|
-
const overlayHandler = withRecording("get_architectural_overlay", handleGetArchitecturalOverlay(storage, masking, staleness), recorder);
|
|
2842
|
+
const overlayHandler = withRecording("get_architectural_overlay", handleGetArchitecturalOverlay(storage, masking, staleness, git, clusterLabelMasker), recorder);
|
|
2588
2843
|
server.registerTool(
|
|
2589
2844
|
"get_architectural_overlay",
|
|
2590
2845
|
{
|
|
@@ -2709,7 +2964,7 @@ function registerTools(server, storage, masking, git, staleness, recorder, ctxoR
|
|
|
2709
2964
|
},
|
|
2710
2965
|
(args) => symbolImportanceHandler(args)
|
|
2711
2966
|
);
|
|
2712
|
-
const prImpactHandler = withRecording("get_pr_impact", handleGetPrImpact(storage, git, masking, staleness, ctxoRoot), recorder);
|
|
2967
|
+
const prImpactHandler = withRecording("get_pr_impact", handleGetPrImpact(storage, git, masking, staleness, ctxoRoot, clusterLabelMasker), recorder);
|
|
2713
2968
|
server.registerTool(
|
|
2714
2969
|
"get_pr_impact",
|
|
2715
2970
|
{
|