@ctxo/cli 0.11.5 → 0.13.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/index.js CHANGED
@@ -3,6 +3,8 @@ import {
3
3
  ArchitecturalOverlay,
4
4
  BlastRadiusCalculator,
5
5
  BoundaryViolationDetector,
6
+ CoChangeReader,
7
+ ContextsViewReader,
6
8
  DeadCodeDetector,
7
9
  DriftDetector,
8
10
  PageRankCalculator,
@@ -11,18 +13,23 @@ import {
11
13
  SimpleGitAdapter,
12
14
  SqliteStorageAdapter,
13
15
  SymbolGraph,
16
+ SymbolTokenizer,
17
+ compareCodepoint,
14
18
  detectWorkspace,
15
- loadCoChangeMap,
19
+ findCoChangedFilesForBlastRadius,
16
20
  loadConfig,
17
21
  makeGlobMatcher,
18
22
  maskingClusterLabelPatterns,
19
23
  statsEnabled
20
- } from "./chunk-HZ4UGA6J.js";
24
+ } from "./chunk-BYX3UFBJ.js";
21
25
  import "./chunk-RFEXMBTG.js";
22
26
  import {
23
27
  DetailLevelSchema,
24
- JsonIndexReader
25
- } from "./chunk-CDGQGCVN.js";
28
+ EdgeKindSchema,
29
+ JsonIndexReader,
30
+ SYMBOL_KINDS,
31
+ SymbolKindSchema
32
+ } from "./chunk-DT3ZH4AU.js";
26
33
  import {
27
34
  createLogger
28
35
  } from "./chunk-5DSKZJNN.js";
@@ -102,10 +109,20 @@ async function startHttpTransport(serverFactory, port) {
102
109
  });
103
110
  }
104
111
 
112
+ // src/cli/http-port.ts
113
+ var DEFAULT_HTTP_PORT = "3001";
114
+ function resolveHttpPort(args, env) {
115
+ const fromEnv = env["CTXO_HTTP_PORT"] || null;
116
+ if (args.length === 0) return fromEnv;
117
+ if (!args.includes("--http")) return null;
118
+ const portFlag = args.indexOf("--port");
119
+ return fromEnv || (portFlag === -1 ? void 0 : args[portFlag + 1]) || DEFAULT_HTTP_PORT;
120
+ }
121
+
105
122
  // src/index.ts
106
123
  import { z as z15 } from "zod";
107
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
108
- import { join as join2, dirname } from "path";
124
+ import { existsSync, readFileSync } from "fs";
125
+ import { join, dirname } from "path";
109
126
  import { fileURLToPath } from "url";
110
127
 
111
128
  // src/core/masking/masking-pipeline.ts
@@ -166,10 +183,10 @@ var LogicSliceQuery = class {
166
183
  getLogicSlice(graph, symbolId, maxDepth = Infinity) {
167
184
  const root = graph.getNode(symbolId);
168
185
  if (!root) return void 0;
169
- const visited = /* @__PURE__ */ new Set([symbolId]);
186
+ const visited = /* @__PURE__ */ new Set([root.symbolId]);
170
187
  const dependencies = [];
171
188
  const collectedEdges = [];
172
- const queue = [{ id: symbolId, depth: 0 }];
189
+ const queue = [{ id: root.symbolId, depth: 0 }];
173
190
  while (queue.length > 0) {
174
191
  const current = queue.shift();
175
192
  if (current.depth >= maxDepth) continue;
@@ -191,6 +208,11 @@ var LogicSliceQuery = class {
191
208
  // src/core/detail-levels/detail-formatter.ts
192
209
  var L1_MAX_LINES = 150;
193
210
  var L4_TOKEN_BUDGET = 8e3;
211
+ function withoutAnnotations(node) {
212
+ if (node.annotations === void 0) return node;
213
+ const { annotations: _annotations, ...rest } = node;
214
+ return rest;
215
+ }
194
216
  var DetailFormatter = class {
195
217
  format(slice, level) {
196
218
  switch (level) {
@@ -209,7 +231,7 @@ var DetailFormatter = class {
209
231
  const lineCount = root.endLine - root.startLine + 1;
210
232
  const clampedRoot = lineCount > L1_MAX_LINES ? { ...root, endLine: root.startLine + L1_MAX_LINES - 1 } : root;
211
233
  return {
212
- root: clampedRoot,
234
+ root: withoutAnnotations(clampedRoot),
213
235
  dependencies: [],
214
236
  edges: [],
215
237
  level: 1,
@@ -222,8 +244,8 @@ var DetailFormatter = class {
222
244
  const directDepIds = new Set(directEdges.map((e) => e.to));
223
245
  const directDeps = slice.dependencies.filter((d) => directDepIds.has(d.symbolId));
224
246
  return {
225
- root: slice.root,
226
- dependencies: directDeps,
247
+ root: withoutAnnotations(slice.root),
248
+ dependencies: directDeps.map(withoutAnnotations),
227
249
  edges: directEdges,
228
250
  level: 2,
229
251
  levelDescription: "L2: Root + direct dependencies (depth 1)"
@@ -231,8 +253,8 @@ var DetailFormatter = class {
231
253
  }
232
254
  formatL3(slice) {
233
255
  return {
234
- root: slice.root,
235
- dependencies: slice.dependencies,
256
+ root: withoutAnnotations(slice.root),
257
+ dependencies: slice.dependencies.map(withoutAnnotations),
236
258
  edges: slice.edges,
237
259
  level: 3,
238
260
  levelDescription: "L3: Full transitive dependency closure"
@@ -313,6 +335,7 @@ var TRUNCATABLE_FIELDS = {
313
335
  deadFiles: "Use find_dead_code with includeTests to adjust scope.",
314
336
  files: "Use get_changed_symbols with a narrower since ref or smaller maxFiles.",
315
337
  clusters: "Use get_architectural_overlay with mode=communities and layer filter for a focused view.",
338
+ candidates: "Use get_architectural_overlay with mode=contexts and candidate: <id> for one candidate in full, or ctxo contexts --json for the full contexts view.",
316
339
  clustersAffected: "Use get_blast_radius on individual changed symbols for per-cluster impact."
317
340
  };
318
341
  function getThreshold() {
@@ -472,10 +495,11 @@ function buildGraphFromStorage(storage) {
472
495
  function buildGraphAndFilesFromJsonIndex(ctxoRoot) {
473
496
  const reader = new JsonIndexReader(ctxoRoot);
474
497
  const indices = reader.readAll();
498
+ return { graph: buildGraphFromIndices(indices), indexedFiles: indices.map((fileIndex) => fileIndex.file) };
499
+ }
500
+ function buildGraphFromIndices(indices) {
475
501
  const graph = new SymbolGraph();
476
- const indexedFiles = [];
477
502
  for (const fileIndex of indices) {
478
- indexedFiles.push(fileIndex.file);
479
503
  for (const sym of fileIndex.symbols) {
480
504
  graph.addNode(sym);
481
505
  }
@@ -485,7 +509,7 @@ function buildGraphAndFilesFromJsonIndex(ctxoRoot) {
485
509
  graph.addEdge(edge);
486
510
  }
487
511
  }
488
- return { graph, indexedFiles };
512
+ return graph;
489
513
  }
490
514
  function getGraphAndFiles(ctxoRoot, storage) {
491
515
  const json = buildGraphAndFilesFromJsonIndex(ctxoRoot);
@@ -585,6 +609,10 @@ async function buildSnapshotStaleness(storage, git) {
585
609
  }
586
610
 
587
611
  // src/adapters/mcp/get-why-context.ts
612
+ function graphOf(indices, storage) {
613
+ const graph = buildGraphFromIndices(indices);
614
+ return graph.nodeCount > 0 ? graph : buildGraphFromStorage(storage);
615
+ }
588
616
  var InputSchema2 = z2.object({
589
617
  symbolId: z2.string().min(1),
590
618
  maxCommits: z2.number().int().min(1).optional()
@@ -601,15 +629,15 @@ function handleGetWhyContext(storage, git, masking, staleness, ctxoRoot = ".ctxo
601
629
  content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
602
630
  };
603
631
  }
604
- const { symbolId, maxCommits } = parsed.data;
605
- const symbol = storage.getSymbolById(symbolId);
606
- if (!symbol) {
632
+ const { symbolId: inputId, maxCommits } = parsed.data;
633
+ const indices = indexReader.readAll();
634
+ const symbolId = storage.getSymbolById(inputId) ? inputId : graphOf(indices, storage).getNode(inputId)?.symbolId;
635
+ if (!symbolId) {
607
636
  return {
608
637
  content: [{ type: "text", text: JSON.stringify({ found: false, hint: 'Symbol not found. Run "ctxo index" to build the codebase index.' }) }]
609
638
  };
610
639
  }
611
640
  const filePath = symbolId.split("::")[0];
612
- const indices = indexReader.readAll();
613
641
  const fileIndex = indices.find((i) => i.file === filePath);
614
642
  let commitHistory;
615
643
  let antiPatterns;
@@ -645,6 +673,7 @@ function handleGetWhyContext(storage, git, masking, staleness, ctxoRoot = ".ctxo
645
673
  events: relevant,
646
674
  confidence: drift.confidence,
647
675
  snapshotsAvailable: drift.snapshotsAvailable,
676
+ distinctCommits: drift.distinctCommits,
648
677
  stabilitySummary: drift.stability,
649
678
  ...drift.hint ? { hint: drift.hint } : {}
650
679
  };
@@ -789,7 +818,7 @@ var InputSchema4 = z4.object({
789
818
  confidence: z4.enum(["confirmed", "likely", "potential"]).optional(),
790
819
  intent: z4.string().optional().describe('Filter results by intent keywords (e.g., "test", "adapter", "security")')
791
820
  });
792
- function handleGetBlastRadius(storage, masking, staleness, ctxoRoot = ".ctxo", git, clusterLabelMasker) {
821
+ function handleGetBlastRadius(storage, masking, staleness, ctxoRoot = ".ctxo", git, clusterLabelMasker, coChange) {
793
822
  const calculator = new BlastRadiusCalculator();
794
823
  const getGraph = () => getGraphAndFiles(ctxoRoot, storage);
795
824
  return async (args) => {
@@ -802,12 +831,15 @@ function handleGetBlastRadius(storage, masking, staleness, ctxoRoot = ".ctxo", g
802
831
  }
803
832
  const { symbolId } = parsed.data;
804
833
  const { graph, indexedFiles } = getGraph();
805
- if (!graph.hasNode(symbolId)) {
834
+ const root = graph.getNode(symbolId);
835
+ if (!root) {
806
836
  return {
807
837
  content: [{ type: "text", text: JSON.stringify({ found: false, hint: 'Symbol not found. Run "ctxo index" to build the codebase index.' }) }]
808
838
  };
809
839
  }
810
- const result = calculator.calculate(graph, symbolId);
840
+ const rootId = root.symbolId;
841
+ const coChangeMap = coChange?.readCoChangeMap();
842
+ const result = calculator.calculate(graph, rootId, coChangeMap);
811
843
  let symbols = result.impactedSymbols;
812
844
  if (parsed.data.confidence) {
813
845
  symbols = symbols.filter((s) => s.confidence === parsed.data.confidence);
@@ -818,7 +850,7 @@ function handleGetBlastRadius(storage, masking, staleness, ctxoRoot = ".ctxo", g
818
850
  const potentialCount = symbols.filter((s) => s.confidence === "potential").length;
819
851
  const rawSnapshot = storage.readCommunities();
820
852
  const snapshot = rawSnapshot && clusterLabelMasker?.isEnabled() ? clusterLabelMasker.maskSnapshot(rawSnapshot) : rawSnapshot;
821
- const clusterBreakdown = snapshot ? computeClusterBreakdown(snapshot, symbolId, symbols.map((s) => s.symbolId)) : void 0;
853
+ const clusterBreakdown = snapshot ? computeClusterBreakdown(snapshot, rootId, symbols.map((s) => s.symbolId)) : void 0;
822
854
  const body = {
823
855
  symbolId,
824
856
  impactScore: symbols.length,
@@ -836,6 +868,8 @@ function handleGetBlastRadius(storage, masking, staleness, ctxoRoot = ".ctxo", g
836
868
  body.multiClusterHint = clusterBreakdown.multiClusterHint;
837
869
  }
838
870
  }
871
+ const coChangedFiles = findCoChangedFilesForBlastRadius(coChangeMap, rootId, result.impactedSymbols);
872
+ if (coChangedFiles.length > 0) body.coChangedFiles = coChangedFiles;
839
873
  const stalenessMeta = git ? await buildSnapshotStaleness(storage, git) : void 0;
840
874
  const extras = stalenessMeta ? { snapshotStaleness: stalenessMeta } : void 0;
841
875
  const payload = masking.mask(JSON.stringify(wrapResponse(body, extras)));
@@ -856,10 +890,11 @@ function handleGetBlastRadius(storage, masking, staleness, ctxoRoot = ".ctxo", g
856
890
 
857
891
  // src/adapters/mcp/get-architectural-overlay.ts
858
892
  import { z as z5 } from "zod";
859
- var OVERLAY_MODES = ["regex", "communities", "both"];
893
+ var OVERLAY_MODES = ["regex", "communities", "both", "contexts"];
860
894
  var InputSchema5 = z5.object({
861
895
  layer: z5.string().optional(),
862
- mode: z5.enum(OVERLAY_MODES).optional()
896
+ mode: z5.enum(OVERLAY_MODES).optional(),
897
+ candidate: z5.number().int().positive().optional()
863
898
  });
864
899
  function buildCommunityOverlay(snapshot, options = {}) {
865
900
  const previewLimit = options.memberPreviewLimit ?? 15;
@@ -904,7 +939,136 @@ function buildCommunityOverlay(snapshot, options = {}) {
904
939
  commitSha: snapshot.commitSha
905
940
  };
906
941
  }
907
- function handleGetArchitecturalOverlay(storage, masking, staleness, git, clusterLabelMasker) {
942
+ var HUB_COLLISION_LIMIT = 50;
943
+ var SUMMARY_TERM_LIMIT = 5;
944
+ var DETAIL_FILE_LIMIT = 15;
945
+ var DETAIL_COLLISION_LIMIT = 10;
946
+ var DETAIL_OCCURRENCE_LIMIT = 5;
947
+ var DETAIL_DATA_LIMIT = 15;
948
+ var DETAIL_ACCESS_LIMIT = 10;
949
+ var NO_VIEW_HINT = "No contexts view available. Run ctxo index to generate one.";
950
+ var SUMMARY_HINT = "Pass candidate: <id> for one candidate in full; run ctxo contexts --json for the whole view.";
951
+ var DETAIL_HINT = "Run ctxo contexts --json for full member sets.";
952
+ function labelMaskerFor(view, labelMasker) {
953
+ const masker = labelMasker?.isEnabled() ? labelMasker : void 0;
954
+ if (!masker) return (label) => label;
955
+ for (const label of [...new Set(view.candidates.map((c) => c.label))].sort(compareCodepoint)) masker.mask(label);
956
+ return (label) => masker.mask(label);
957
+ }
958
+ function collisionSummary(collision) {
959
+ return {
960
+ name: collision.name,
961
+ minJaccard: collision.minJaccard,
962
+ candidates: [...new Set(collision.occurrences.map((occurrence) => occurrence.candidate))].sort((a, b) => a - b),
963
+ occurrenceCount: collision.occurrences.length
964
+ };
965
+ }
966
+ function contextsSummary(view, maskLabel) {
967
+ return {
968
+ commitSha: view.commitSha,
969
+ coverage: view.coverage,
970
+ candidates: view.candidates.map((candidate) => ({
971
+ id: candidate.id,
972
+ label: maskLabel(candidate.label),
973
+ fingerprint: candidate.fingerprint,
974
+ fileCount: candidate.fileCount,
975
+ cohesion: candidate.cohesion,
976
+ topTerms: candidate.topTerms.slice(0, SUMMARY_TERM_LIMIT),
977
+ ...candidate.sharedTables.length > 0 ? { sharedTableCount: candidate.sharedTables.length } : {}
978
+ })),
979
+ hubs: view.hubs.slice(0, HUB_COLLISION_LIMIT).map((hub) => ({
980
+ file: hub.file,
981
+ reasons: hub.reasons,
982
+ ...hub.candidate !== void 0 ? { candidate: hub.candidate } : {}
983
+ })),
984
+ hubsTotal: view.hubs.length,
985
+ collisions: view.collisions.slice(0, HUB_COLLISION_LIMIT).map(collisionSummary),
986
+ collisionsTotal: view.collisions.length,
987
+ unclustered: { count: view.unclustered.count },
988
+ dataResourcesTotal: view.dataResources.length,
989
+ sharedResourcesTotal: view.dataResources.filter((resource) => resource.accesses.length >= 2).length,
990
+ externalResourcesTotal: view.dataResources.filter((resource) => resource.external).length,
991
+ hint: SUMMARY_HINT
992
+ };
993
+ }
994
+ function sharedTableDetail(table, id) {
995
+ const own = table.accesses.filter((access) => access.candidate === id);
996
+ const others = table.accesses.filter((access) => access.candidate !== id).slice(0, DETAIL_ACCESS_LIMIT - own.length);
997
+ return {
998
+ ...table,
999
+ accesses: [...own, ...others].sort((a, b) => a.candidate - b.candidate),
1000
+ accessCount: table.accesses.length
1001
+ };
1002
+ }
1003
+ function ownDataResources(view, id) {
1004
+ const own = [];
1005
+ for (const resource of view.dataResources) {
1006
+ const access = resource.accesses.find((entry) => entry.candidate === id);
1007
+ if (!access) continue;
1008
+ own.push({
1009
+ name: resource.name,
1010
+ ...resource.kind !== void 0 ? { kind: resource.kind } : {},
1011
+ external: resource.external,
1012
+ modes: access.modes,
1013
+ ...resource.owner !== void 0 ? { owner: resource.owner } : {},
1014
+ writerCount: resource.writers.length
1015
+ });
1016
+ }
1017
+ return own;
1018
+ }
1019
+ function contextsDetail(view, id, maskLabel, extras) {
1020
+ const candidate = view.candidates.find((c) => c.id === id);
1021
+ if (!candidate) {
1022
+ return { found: false, hint: `No candidate ${id} in the contexts view. Call without candidate for the list.` };
1023
+ }
1024
+ const hubs = view.hubs.filter((hub) => hub.candidate === id);
1025
+ const collisions = view.collisions.filter(
1026
+ (collision) => collision.occurrences.some((occurrence) => occurrence.candidate === id)
1027
+ );
1028
+ const dataResources = ownDataResources(view, id);
1029
+ const shownTables = candidate.sharedTables.slice(0, DETAIL_DATA_LIMIT).map((table) => sharedTableDetail(table, id));
1030
+ const shownResources = dataResources.slice(0, DETAIL_DATA_LIMIT);
1031
+ const body = {
1032
+ commitSha: view.commitSha,
1033
+ candidate: {
1034
+ ...candidate,
1035
+ label: maskLabel(candidate.label),
1036
+ files: candidate.files.slice(0, DETAIL_FILE_LIMIT),
1037
+ sharedTables: shownTables,
1038
+ sharedTableCount: candidate.sharedTables.length
1039
+ },
1040
+ hubs: hubs.slice(0, HUB_COLLISION_LIMIT).map((hub) => ({ file: hub.file, reasons: hub.reasons, dependents: hub.dependents, damping: hub.damping })),
1041
+ hubsTotal: hubs.length,
1042
+ collisions: collisions.slice(0, DETAIL_COLLISION_LIMIT).map((collision) => ({
1043
+ ...collisionSummary(collision),
1044
+ occurrences: collision.occurrences.filter((occurrence) => occurrence.candidate === id).slice(0, DETAIL_OCCURRENCE_LIMIT).map((occurrence) => ({
1045
+ symbolId: occurrence.symbolId,
1046
+ members: occurrence.members.slice(0, DETAIL_OCCURRENCE_LIMIT),
1047
+ memberCount: occurrence.members.length
1048
+ }))
1049
+ })),
1050
+ collisionsTotal: collisions.length,
1051
+ dataResources: shownResources,
1052
+ dataResourcesTotal: dataResources.length,
1053
+ hint: DETAIL_HINT
1054
+ };
1055
+ fitDataToLimit(body, shownTables, shownResources, extras);
1056
+ return body;
1057
+ }
1058
+ function fitDataToLimit(body, tables, resources, extras) {
1059
+ const limit = getThreshold();
1060
+ const size = () => Buffer.byteLength(JSON.stringify(wrapResponse(body, extras)), "utf-8");
1061
+ while ((tables.length > 0 || resources.length > 0) && size() > limit) {
1062
+ if (resources.length >= tables.length) resources.pop();
1063
+ else tables.pop();
1064
+ }
1065
+ }
1066
+ function buildContextsOverlay(view, options = {}) {
1067
+ if (!view) return { found: false, hint: NO_VIEW_HINT };
1068
+ const maskLabel = labelMaskerFor(view, options.labelMasker);
1069
+ return options.candidate === void 0 ? contextsSummary(view, maskLabel) : contextsDetail(view, options.candidate, maskLabel, options.extras);
1070
+ }
1071
+ function handleGetArchitecturalOverlay(storage, masking, staleness, git, clusterLabelMasker, contextsView) {
908
1072
  const overlay = new ArchitecturalOverlay();
909
1073
  return async (args) => {
910
1074
  try {
@@ -935,6 +1099,15 @@ function handleGetArchitecturalOverlay(storage, masking, staleness, git, cluster
935
1099
  );
936
1100
  return { content: buildContent(payload2) };
937
1101
  }
1102
+ if (mode === "contexts") {
1103
+ const body2 = buildContextsOverlay(contextsView?.readContextsView(), {
1104
+ candidate: parsed.data.candidate,
1105
+ labelMasker: clusterLabelMasker,
1106
+ extras
1107
+ });
1108
+ const payload2 = masking.mask(JSON.stringify(wrapResponse(body2, extras)));
1109
+ return { content: buildContent(payload2) };
1110
+ }
938
1111
  const rawSnapshot = mode === "regex" ? void 0 : storage.readCommunities();
939
1112
  const snapshot = rawSnapshot && clusterLabelMasker?.isEnabled() ? clusterLabelMasker.maskSnapshot(rawSnapshot) : rawSnapshot;
940
1113
  const community = snapshot ? buildCommunityOverlay(snapshot) : void 0;
@@ -1036,12 +1209,18 @@ var DEFAULT_TOKEN_BUDGET = 4e3;
1036
1209
  var ContextAssembler = class {
1037
1210
  logicSlice = new LogicSliceQuery();
1038
1211
  blastRadius = new BlastRadiusCalculator();
1039
- assembleForTask(graph, symbolId, taskType, indices, tokenBudget = DEFAULT_TOKEN_BUDGET) {
1212
+ /**
1213
+ * `coChangeMap` is the same lookup `get_blast_radius` uses: with it, a potential dependent whose file
1214
+ * co-changes with the target's becomes likely. It changes the tier shown on an entry, not which entries
1215
+ * make the context or how they rank.
1216
+ */
1217
+ assembleForTask(graph, symbolId, taskType, indices, tokenBudget = DEFAULT_TOKEN_BUDGET, coChangeMap) {
1040
1218
  const target = graph.getNode(symbolId);
1041
1219
  if (!target) return void 0;
1220
+ const targetId = target.symbolId;
1042
1221
  const weights = getWeightsForTask(taskType);
1043
- const slice = this.logicSlice.getLogicSlice(graph, symbolId);
1044
- const blastResult = this.blastRadius.calculate(graph, symbolId);
1222
+ const slice = this.logicSlice.getLogicSlice(graph, targetId);
1223
+ const blastResult = this.blastRadius.calculate(graph, targetId, coChangeMap);
1045
1224
  const candidateMap = /* @__PURE__ */ new Map();
1046
1225
  if (slice) {
1047
1226
  for (const dep of slice.dependencies) {
@@ -1057,10 +1236,11 @@ var ContextAssembler = class {
1057
1236
  const existing = candidateMap.get(entry.symbolId);
1058
1237
  if (existing) {
1059
1238
  existing.signals.isDependent = true;
1239
+ existing.signals.dependentConfidence = entry.confidence;
1060
1240
  } else {
1061
1241
  candidateMap.set(entry.symbolId, {
1062
1242
  node,
1063
- signals: { isDirectDep: false, isInterfaceOrType: this.isInterfaceOrType(node.kind), isDependent: true, complexity: 0, hasAntiPattern: false }
1243
+ signals: { isDirectDep: false, isInterfaceOrType: this.isInterfaceOrType(node.kind), isDependent: true, dependentConfidence: entry.confidence, complexity: 0, hasAntiPattern: false }
1064
1244
  });
1065
1245
  }
1066
1246
  }
@@ -1087,12 +1267,12 @@ var ContextAssembler = class {
1087
1267
  totalTokens += entry.tokens;
1088
1268
  }
1089
1269
  const warnings = [];
1090
- const targetFile = symbolId.split("::")[0] ?? "";
1270
+ const targetFile = targetId.split("::")[0] ?? "";
1091
1271
  const targetIndex = fileIndexMap.get(targetFile);
1092
1272
  if (targetIndex && targetIndex.antiPatterns.length > 0) {
1093
1273
  warnings.push(`\u26A0 Target symbol has ${targetIndex.antiPatterns.length} anti-pattern(s) in file history`);
1094
1274
  }
1095
- return { target, taskType, context, totalTokens, tokenBudget, warnings };
1275
+ return { target: withoutAnnotations(target), taskType, context, totalTokens, tokenBudget, warnings };
1096
1276
  }
1097
1277
  assembleRanked(graph, query, strategy = "combined", tokenBudget = DEFAULT_TOKEN_BUDGET) {
1098
1278
  const allNodes = graph.allNodes();
@@ -1169,7 +1349,8 @@ var ContextAssembler = class {
1169
1349
  relevanceScore: this.computeScore(signals, weights),
1170
1350
  reason: reasons.join(", ") || "related symbol",
1171
1351
  lines: node.endLine - node.startLine + 1,
1172
- tokens: this.estimateTokens(node)
1352
+ tokens: this.estimateTokens(node),
1353
+ ...signals.dependentConfidence ? { confidence: signals.dependentConfidence } : {}
1173
1354
  };
1174
1355
  }
1175
1356
  isInterfaceOrType(kind) {
@@ -1185,14 +1366,7 @@ var ContextAssembler = class {
1185
1366
  return (node.endLine - node.startLine + 1) * 10;
1186
1367
  }
1187
1368
  };
1188
- var VALID_SYMBOL_KINDS = /* @__PURE__ */ new Set([
1189
- "function",
1190
- "class",
1191
- "interface",
1192
- "method",
1193
- "variable",
1194
- "type"
1195
- ]);
1369
+ var VALID_SYMBOL_KINDS = new Set(SYMBOL_KINDS);
1196
1370
  function synthesizeNodeFromId(symbolId) {
1197
1371
  const parts = symbolId.split("::");
1198
1372
  if (parts.length !== 3) return void 0;
@@ -1209,7 +1383,7 @@ var InputSchema7 = z7.object({
1209
1383
  taskType: TaskTypeSchema,
1210
1384
  tokenBudget: z7.number().min(100).optional().default(4e3)
1211
1385
  });
1212
- function handleGetContextForTask(storage, masking, staleness, ctxoRoot = ".ctxo") {
1386
+ function handleGetContextForTask(storage, masking, staleness, ctxoRoot = ".ctxo", coChange) {
1213
1387
  const assembler = new ContextAssembler();
1214
1388
  const indexReader = new JsonIndexReader(ctxoRoot);
1215
1389
  const getGraph = () => getGraphAndFiles(ctxoRoot, storage);
@@ -1224,7 +1398,7 @@ function handleGetContextForTask(storage, masking, staleness, ctxoRoot = ".ctxo"
1224
1398
  const { symbolId, taskType, tokenBudget } = parsed.data;
1225
1399
  const { graph, indexedFiles } = getGraph();
1226
1400
  const indices = indexReader.readAll();
1227
- const result = assembler.assembleForTask(graph, symbolId, taskType, indices, tokenBudget);
1401
+ const result = assembler.assembleForTask(graph, symbolId, taskType, indices, tokenBudget, coChange?.readCoChangeMap());
1228
1402
  if (!result) {
1229
1403
  return {
1230
1404
  content: [{ type: "text", text: JSON.stringify({ found: false, hint: 'Symbol not found. Run "ctxo index".' }) }]
@@ -1249,179 +1423,6 @@ function handleGetContextForTask(storage, masking, staleness, ctxoRoot = ".ctxo"
1249
1423
  // src/adapters/mcp/get-ranked-context.ts
1250
1424
  import { z as z8 } from "zod";
1251
1425
 
1252
- // src/core/search/symbol-tokenizer.ts
1253
- var STOP_WORDS = /* @__PURE__ */ new Set([
1254
- "get",
1255
- "set",
1256
- "is",
1257
- "has",
1258
- "can",
1259
- "do",
1260
- "the",
1261
- "a",
1262
- "an",
1263
- "of",
1264
- "to",
1265
- "in",
1266
- "for",
1267
- "on",
1268
- "with",
1269
- "at",
1270
- "by",
1271
- "from",
1272
- "as",
1273
- "into",
1274
- "not",
1275
- "no",
1276
- "or"
1277
- ]);
1278
- var DEFAULT_OPTIONS = {
1279
- includeStopWords: false,
1280
- includeOriginal: true,
1281
- includeFilePath: false
1282
- };
1283
- var SymbolTokenizer = class _SymbolTokenizer {
1284
- options;
1285
- constructor(options) {
1286
- this.options = { ...DEFAULT_OPTIONS, ...options };
1287
- }
1288
- /**
1289
- * Tokenize a symbol name into searchable tokens.
1290
- *
1291
- * Examples:
1292
- * "getCoChangeMetrics" → ["get", "co", "change", "metrics"] (or without stop words: ["co", "change", "metrics"])
1293
- * "SqliteStorageAdapter" → ["sqlite", "storage", "adapter"]
1294
- * "i_storage_port" → ["i", "storage", "port"]
1295
- * "BM25Scorer" → ["bm", "25", "scorer"]
1296
- * "DB_PASSWORD" → ["db", "password"]
1297
- */
1298
- tokenize(symbolName, filePath) {
1299
- const splitTokens = _SymbolTokenizer.splitName(symbolName);
1300
- const lowered = splitTokens.map((t) => t.toLowerCase());
1301
- const tokens = this.options.includeStopWords ? lowered : lowered.filter((t) => !STOP_WORDS.has(t));
1302
- if (this.options.includeOriginal) {
1303
- const original = symbolName.toLowerCase();
1304
- if (!tokens.includes(original)) {
1305
- tokens.unshift(original);
1306
- }
1307
- }
1308
- if (this.options.includeFilePath && filePath) {
1309
- const pathTokens = _SymbolTokenizer.tokenizeFilePath(filePath);
1310
- for (const pt of pathTokens) {
1311
- if (!tokens.includes(pt)) {
1312
- tokens.push(pt);
1313
- }
1314
- }
1315
- }
1316
- return tokens;
1317
- }
1318
- /**
1319
- * Tokenize a search query. Queries use stop words and don't include originals.
1320
- */
1321
- tokenizeQuery(query) {
1322
- const words = query.trim().split(/\s+/);
1323
- const tokens = [];
1324
- for (const word of words) {
1325
- const split = _SymbolTokenizer.splitName(word);
1326
- for (const t of split) {
1327
- const lowered = t.toLowerCase();
1328
- if (lowered.length > 0 && !tokens.includes(lowered)) {
1329
- tokens.push(lowered);
1330
- }
1331
- }
1332
- }
1333
- return tokens;
1334
- }
1335
- /**
1336
- * Split a name into constituent words based on naming convention boundaries.
1337
- *
1338
- * Rules:
1339
- * 1. Split on underscores and hyphens (snake_case, kebab-case)
1340
- * 2. Split on camelCase/PascalCase boundaries (uppercase after lowercase)
1341
- * 3. Split acronym boundaries (e.g., "HTMLParser" → ["HTML", "Parser"])
1342
- * 4. Split on digit ↔ letter boundaries
1343
- */
1344
- static splitName(name) {
1345
- if (!name || name.length === 0) return [];
1346
- const segments = name.split(/[_\-./\\]+/).filter((s) => s.length > 0);
1347
- const tokens = [];
1348
- for (const segment of segments) {
1349
- tokens.push(..._SymbolTokenizer.splitCamelCase(segment));
1350
- }
1351
- return tokens.filter((t) => t.length > 0);
1352
- }
1353
- /**
1354
- * Split a single segment on camelCase/PascalCase/digit boundaries.
1355
- */
1356
- static splitCamelCase(segment) {
1357
- const tokens = [];
1358
- let current = "";
1359
- for (let i = 0; i < segment.length; i++) {
1360
- const ch = segment[i];
1361
- const prev = i > 0 ? segment[i - 1] : "";
1362
- if (current.length > 0) {
1363
- const prevIsDigit = isDigit(prev);
1364
- const currIsDigit = isDigit(ch);
1365
- if (prevIsDigit !== currIsDigit) {
1366
- tokens.push(current);
1367
- current = ch;
1368
- continue;
1369
- }
1370
- }
1371
- if (current.length > 0 && isLower(prev) && isUpper(ch)) {
1372
- tokens.push(current);
1373
- current = ch;
1374
- continue;
1375
- }
1376
- if (current.length > 1 && isUpper(ch) === false && isUpper(prev) && !isDigit(ch)) {
1377
- const lastChar = current[current.length - 1];
1378
- if (isUpper(lastChar)) {
1379
- tokens.push(current.slice(0, -1));
1380
- current = lastChar + ch;
1381
- continue;
1382
- }
1383
- }
1384
- current += ch;
1385
- }
1386
- if (current.length > 0) {
1387
- tokens.push(current);
1388
- }
1389
- return tokens;
1390
- }
1391
- /**
1392
- * Extract searchable tokens from a file path.
1393
- * "src/core/search/symbol-tokenizer.ts" → ["src", "core", "search", "symbol", "tokenizer", "ts"]
1394
- */
1395
- static tokenizeFilePath(filePath) {
1396
- const withoutExt = filePath.replace(/\.[^.]+$/, "");
1397
- const segments = withoutExt.split(/[/\\]+/);
1398
- const tokens = [];
1399
- for (const seg of segments) {
1400
- const parts = seg.split(/[_\-.]+/).filter((s) => s.length > 0);
1401
- for (const p of parts) {
1402
- const lowered = p.toLowerCase();
1403
- if (lowered.length > 0 && !tokens.includes(lowered)) {
1404
- tokens.push(lowered);
1405
- }
1406
- }
1407
- }
1408
- return tokens;
1409
- }
1410
- /** Check if a token is a stop word */
1411
- static isStopWord(token) {
1412
- return STOP_WORDS.has(token.toLowerCase());
1413
- }
1414
- };
1415
- function isUpper(ch) {
1416
- return ch >= "A" && ch <= "Z";
1417
- }
1418
- function isLower(ch) {
1419
- return ch >= "a" && ch <= "z";
1420
- }
1421
- function isDigit(ch) {
1422
- return ch >= "0" && ch <= "9";
1423
- }
1424
-
1425
1426
  // src/core/search/fuzzy-corrector.ts
1426
1427
  var FuzzyCorrector = class {
1427
1428
  /** term → frequency (for tie-breaking) */
@@ -1805,7 +1806,6 @@ var InputSchema8 = z8.object({
1805
1806
  query: z8.string().min(1),
1806
1807
  tokenBudget: z8.number().min(100).optional().default(4e3),
1807
1808
  strategy: z8.enum(["combined", "dependency", "importance"]).optional().default("combined"),
1808
- fuzzy: z8.boolean().optional().default(true),
1809
1809
  searchMode: z8.enum(["fts", "legacy"]).optional().default("fts")
1810
1810
  });
1811
1811
  function handleGetRankedContext(storage, masking, staleness, ctxoRoot = ".ctxo", searchEngine) {
@@ -1898,7 +1898,7 @@ function handleGetRankedContext(storage, masking, staleness, ctxoRoot = ".ctxo",
1898
1898
  import { z as z9 } from "zod";
1899
1899
  var InputSchema9 = z9.object({
1900
1900
  pattern: z9.string().min(1),
1901
- kind: z9.enum(["function", "class", "interface", "method", "variable", "type"]).optional(),
1901
+ kind: SymbolKindSchema.optional(),
1902
1902
  filePattern: z9.string().optional(),
1903
1903
  limit: z9.number().int().min(1).max(100).optional().default(25),
1904
1904
  mode: z9.enum(["regex", "fts"]).optional().default("regex")
@@ -2062,7 +2062,7 @@ function handleGetChangedSymbols(storage, git, masking, staleness, ctxoRoot = ".
2062
2062
  import { z as z11 } from "zod";
2063
2063
  var InputSchema11 = z11.object({
2064
2064
  symbolId: z11.string().min(1),
2065
- edgeKinds: z11.array(z11.enum(["imports", "calls", "extends", "implements", "uses"])).optional(),
2065
+ edgeKinds: z11.array(EdgeKindSchema).optional(),
2066
2066
  transitive: z11.boolean().optional().default(false),
2067
2067
  maxDepth: z11.number().int().min(1).max(10).optional().default(5),
2068
2068
  intent: z11.string().optional().describe('Filter results by intent keywords (e.g., "test", "adapter", "core")')
@@ -2079,14 +2079,16 @@ function handleFindImporters(storage, masking, staleness, ctxoRoot = ".ctxo") {
2079
2079
  }
2080
2080
  const { symbolId, edgeKinds, transitive, maxDepth } = parsed.data;
2081
2081
  const { graph, indexedFiles } = getGraph();
2082
- if (!graph.hasNode(symbolId)) {
2082
+ const root = graph.getNode(symbolId);
2083
+ if (!root) {
2083
2084
  return {
2084
2085
  content: [{ type: "text", text: JSON.stringify({ found: false, hint: 'Symbol not found. Run "ctxo index" to build the codebase index.' }) }]
2085
2086
  };
2086
2087
  }
2088
+ const rootId = root.symbolId;
2087
2089
  const importers = [];
2088
2090
  if (!transitive) {
2089
- const reverseEdges = graph.getReverseEdges(symbolId);
2091
+ const reverseEdges = graph.getDependentEdges(rootId);
2090
2092
  const seen = /* @__PURE__ */ new Map();
2091
2093
  for (const edge of reverseEdges) {
2092
2094
  if (edgeKinds && !edgeKinds.includes(edge.kind)) continue;
@@ -2113,12 +2115,13 @@ function handleFindImporters(storage, masking, staleness, ctxoRoot = ".ctxo") {
2113
2115
  }
2114
2116
  }
2115
2117
  } else {
2116
- const visited = /* @__PURE__ */ new Set([symbolId]);
2117
- const queue = [{ id: symbolId, depth: 0 }];
2118
+ const definitions = graph.dataResourceIdentity(rootId)?.definitions.map((definition) => definition.symbolId) ?? [];
2119
+ const visited = /* @__PURE__ */ new Set([rootId, ...definitions]);
2120
+ const queue = [{ id: rootId, depth: 0 }];
2118
2121
  while (queue.length > 0) {
2119
2122
  const current = queue.shift();
2120
2123
  if (current.depth >= maxDepth) continue;
2121
- const reverseEdges = graph.getReverseEdges(current.id);
2124
+ const reverseEdges = graph.getDependentEdges(current.id);
2122
2125
  for (const edge of reverseEdges) {
2123
2126
  if (edgeKinds && !edgeKinds.includes(edge.kind)) continue;
2124
2127
  if (visited.has(edge.from)) continue;
@@ -2163,6 +2166,7 @@ function handleFindImporters(storage, masking, staleness, ctxoRoot = ".ctxo") {
2163
2166
 
2164
2167
  // src/adapters/mcp/get-class-hierarchy.ts
2165
2168
  import { z as z12 } from "zod";
2169
+ var HIERARCHY_EDGE_KINDS = /* @__PURE__ */ new Set(["extends", "implements"]);
2166
2170
  var InputSchema12 = z12.object({
2167
2171
  symbolId: z12.string().min(1).optional(),
2168
2172
  direction: z12.enum(["ancestors", "descendants", "both"]).optional().default("both")
@@ -2197,7 +2201,7 @@ function handleGetClassHierarchy(storage, masking, staleness, ctxoRoot = ".ctxo"
2197
2201
  }
2198
2202
  const ancestors = [];
2199
2203
  const descendants = [];
2200
- const isHierarchyEdge = (kind) => kind === "extends" || kind === "implements";
2204
+ const isHierarchyEdge = (kind) => HIERARCHY_EDGE_KINDS.has(kind);
2201
2205
  if (direction === "ancestors" || direction === "both") {
2202
2206
  const visited = /* @__PURE__ */ new Set([symbolId]);
2203
2207
  const queue = [{ id: symbolId, depth: 0 }];
@@ -2261,7 +2265,7 @@ function handleGetClassHierarchy(storage, masking, staleness, ctxoRoot = ".ctxo"
2261
2265
  return { content };
2262
2266
  }
2263
2267
  function handleFull(graph, maskingPort, stalenessCheck, indexedFiles) {
2264
- const isHierarchyEdge = (kind) => kind === "extends" || kind === "implements";
2268
+ const isHierarchyEdge = (kind) => HIERARCHY_EDGE_KINDS.has(kind);
2265
2269
  const hierarchyEdges = graph.allEdges().filter((e) => isHierarchyEdge(e.kind));
2266
2270
  const involved = /* @__PURE__ */ new Set();
2267
2271
  for (const edge of hierarchyEdges) {
@@ -2319,7 +2323,7 @@ function handleGetClassHierarchy(storage, masking, staleness, ctxoRoot = ".ctxo"
2319
2323
  import { z as z13 } from "zod";
2320
2324
  var InputSchema13 = z13.object({
2321
2325
  limit: z13.number().int().min(1).max(200).optional().default(25),
2322
- kind: z13.enum(["function", "class", "interface", "method", "variable", "type"]).optional(),
2326
+ kind: SymbolKindSchema.optional(),
2323
2327
  filePattern: z13.string().optional(),
2324
2328
  damping: z13.number().min(0).max(1).optional().default(0.85)
2325
2329
  });
@@ -2370,23 +2374,11 @@ function handleGetSymbolImportance(storage, masking, staleness, ctxoRoot = ".ctx
2370
2374
 
2371
2375
  // src/adapters/mcp/get-pr-impact.ts
2372
2376
  import { z as z14 } from "zod";
2373
- import { readFileSync, existsSync } from "fs";
2374
- import { join } from "path";
2375
2377
  var InputSchema14 = z14.object({
2376
2378
  since: z14.string().optional().default("HEAD~1"),
2377
2379
  maxFiles: z14.number().int().min(1).optional().default(50),
2378
2380
  confidence: z14.enum(["confirmed", "likely", "potential"]).optional()
2379
2381
  });
2380
- function loadCoChanges(ctxoRoot) {
2381
- const path = join(ctxoRoot, "index", "co-changes.json");
2382
- if (!existsSync(path)) return void 0;
2383
- try {
2384
- const matrix = JSON.parse(readFileSync(path, "utf-8"));
2385
- return loadCoChangeMap(matrix);
2386
- } catch {
2387
- return void 0;
2388
- }
2389
- }
2390
2382
  function computeClustersAffected(snapshot, changedSymbolIds) {
2391
2383
  const totals = /* @__PURE__ */ new Map();
2392
2384
  for (const entry of snapshot.communities) {
@@ -2400,7 +2392,7 @@ function computeClustersAffected(snapshot, changedSymbolIds) {
2400
2392
  }
2401
2393
  return [...totals.entries()].map(([id, { label, count }]) => ({ id, label, symbolCount: count })).sort((a, b) => b.symbolCount - a.symbolCount || a.id - b.id);
2402
2394
  }
2403
- function handleGetPrImpact(storage, git, masking, staleness, ctxoRoot = ".ctxo", clusterLabelMasker) {
2395
+ function handleGetPrImpact(storage, git, masking, staleness, ctxoRoot = ".ctxo", clusterLabelMasker, coChange) {
2404
2396
  const calculator = new BlastRadiusCalculator();
2405
2397
  const getGraph = () => getGraphAndFiles(ctxoRoot, storage);
2406
2398
  return async (args) => {
@@ -2419,7 +2411,7 @@ function handleGetPrImpact(storage, git, masking, staleness, ctxoRoot = ".ctxo",
2419
2411
  };
2420
2412
  }
2421
2413
  const { graph, indexedFiles } = getGraph();
2422
- const coChangeMap = loadCoChanges(ctxoRoot);
2414
+ const coChangeMap = coChange?.readCoChangeMap();
2423
2415
  const allNodes = graph.allNodes();
2424
2416
  const nodesByFile = /* @__PURE__ */ new Map();
2425
2417
  for (const node of allNodes) {
@@ -2478,7 +2470,7 @@ function handleGetPrImpact(storage, git, masking, staleness, ctxoRoot = ".ctxo",
2478
2470
  if (coChangeMap) {
2479
2471
  const entries = coChangeMap.get(filePath);
2480
2472
  if (entries && entries.length > 0) {
2481
- coChangedWith = entries.sort((a, b) => b.frequency - a.frequency).slice(0, 5).map((e) => e.file1 === filePath ? e.file2 : e.file1);
2473
+ coChangedWith = [...entries].sort((a, b) => b.frequency - a.frequency).slice(0, 5).map((e) => e.file1 === filePath ? e.file2 : e.file1);
2482
2474
  }
2483
2475
  }
2484
2476
  files.push({
@@ -2512,6 +2504,7 @@ function handleGetPrImpact(storage, git, masking, staleness, ctxoRoot = ".ctxo",
2512
2504
  violations: relevant,
2513
2505
  confidence: violationResult.confidence,
2514
2506
  snapshotsAvailable: violationResult.snapshotsAvailable,
2507
+ distinctCommits: violationResult.distinctCommits,
2515
2508
  ...violationResult.hint ? { hint: violationResult.hint } : {}
2516
2509
  };
2517
2510
  clustersAffected = computeClustersAffected(snapshot, changedSymbolIds);
@@ -2633,10 +2626,10 @@ var ClusterLabelMasker = class {
2633
2626
  // src/index.ts
2634
2627
  var log4 = createLogger("ctxo:mcp");
2635
2628
  function loadMaskingConfig(ctxoRoot) {
2636
- const jsonConfigPath = join2(ctxoRoot, "masking.json");
2637
- if (existsSync2(jsonConfigPath)) {
2629
+ const jsonConfigPath = join(ctxoRoot, "masking.json");
2630
+ if (existsSync(jsonConfigPath)) {
2638
2631
  try {
2639
- const raw = readFileSync2(jsonConfigPath, "utf-8");
2632
+ const raw = readFileSync(jsonConfigPath, "utf-8");
2640
2633
  const patterns = JSON.parse(raw);
2641
2634
  log4.info(`Loaded ${patterns.length} custom masking pattern(s)`);
2642
2635
  return MaskingPipeline.fromConfig(patterns);
@@ -2653,7 +2646,7 @@ var PKG_VERSION = (() => {
2653
2646
  try {
2654
2647
  const __filename = fileURLToPath(import.meta.url);
2655
2648
  const __dirname = dirname(__filename);
2656
- const pkg = JSON.parse(readFileSync2(join2(__dirname, "..", "package.json"), "utf-8"));
2649
+ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
2657
2650
  return pkg.version ?? "0.0.0";
2658
2651
  } catch {
2659
2652
  return "0.0.0";
@@ -2661,9 +2654,9 @@ var PKG_VERSION = (() => {
2661
2654
  })();
2662
2655
  async function main() {
2663
2656
  const args = process.argv.slice(2);
2664
- const httpPortStr = process.env.CTXO_HTTP_PORT || (args.includes("--http") ? args[args.indexOf("--port") + 1] || "3001" : null);
2657
+ const httpPortStr = resolveHttpPort(args, process.env);
2665
2658
  if (!httpPortStr && args.length > 0) {
2666
- const { CliRouter } = await import("./cli-router-LO2SWFC4.js");
2659
+ const { CliRouter } = await import("./cli-router-RR4CM62C.js");
2667
2660
  const router = new CliRouter(process.cwd());
2668
2661
  await router.route(args);
2669
2662
  return;
@@ -2678,15 +2671,16 @@ async function main() {
2678
2671
  maskingClusterLabelPatterns(loadConfig(ctxoRoot).config)
2679
2672
  );
2680
2673
  const git = new SimpleGitAdapter(process.cwd());
2674
+ const coChange = new CoChangeReader(ctxoRoot);
2681
2675
  const recorder = readStatsEnabled(ctxoRoot) ? new SessionRecorderAdapter(storage.getDb(), () => storage.persist()) : null;
2682
2676
  const server = new McpServer({ name: "ctxo", version: PKG_VERSION });
2683
2677
  const { StalenessDetector } = await import("./staleness-detector-4HJYBQ7Z.js");
2684
2678
  const staleness = new StalenessDetector(process.cwd(), ctxoRoot);
2685
- registerTools(server, storage, masking, git, staleness, recorder, ctxoRoot, clusterLabelMasker);
2679
+ registerTools(server, storage, masking, git, staleness, recorder, ctxoRoot, clusterLabelMasker, coChange);
2686
2680
  if (httpPortStr) {
2687
2681
  await startHttpTransport(async () => {
2688
2682
  const s = new McpServer({ name: "ctxo", version: PKG_VERSION });
2689
- registerTools(s, storage, masking, git, staleness, recorder, ctxoRoot, clusterLabelMasker);
2683
+ registerTools(s, storage, masking, git, staleness, recorder, ctxoRoot, clusterLabelMasker, coChange);
2690
2684
  return s;
2691
2685
  }, parseInt(httpPortStr, 10));
2692
2686
  } else {
@@ -2694,24 +2688,25 @@ async function main() {
2694
2688
  await server.connect(transport);
2695
2689
  }
2696
2690
  }
2697
- function registerTools(server, storage, masking, git, staleness, recorder, ctxoRoot = ".ctxo", clusterLabelMasker) {
2691
+ function registerTools(server, storage, masking, git, staleness, recorder, ctxoRoot = ".ctxo", clusterLabelMasker, coChange) {
2698
2692
  const toolAnnotations = {
2699
2693
  readOnlyHint: true,
2700
2694
  destructiveHint: false,
2701
2695
  idempotentHint: true,
2702
2696
  openWorldHint: false
2703
2697
  };
2698
+ const SYMBOL_ID_FORMAT = "format: file::name::kind; a SQL table, view or procedure also as name::kind, for example dbo.loans::table";
2704
2699
  const logicSliceHandler = withRecording("get_logic_slice", handleGetLogicSlice(storage, masking, staleness, ctxoRoot), recorder);
2705
2700
  const whyContextHandler = withRecording("get_why_context", handleGetWhyContext(storage, git, masking, staleness, ctxoRoot, clusterLabelMasker), recorder);
2706
2701
  const changeIntelligenceHandler = withRecording("get_change_intelligence", handleGetChangeIntelligence(storage, git, masking, staleness, ctxoRoot), recorder);
2707
2702
  server.registerTool(
2708
2703
  "get_logic_slice",
2709
2704
  {
2710
- 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.',
2705
+ 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 and symbol annotations (attributes, columns; only L4 includes them). 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.',
2711
2706
  inputSchema: {
2712
- symbolId: z15.string().optional().describe("Single symbol ID (format: file::name::kind)"),
2707
+ symbolId: z15.string().optional().describe(`Single symbol ID (${SYMBOL_ID_FORMAT})`),
2713
2708
  symbolIds: z15.array(z15.string()).optional().describe("Batch: array of symbol IDs"),
2714
- 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)"),
2709
+ 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 and annotations)"),
2715
2710
  intent: z15.string().optional().describe('Filter dependencies by intent keywords (e.g., "core", "adapter")')
2716
2711
  },
2717
2712
  annotations: toolAnnotations
@@ -2723,7 +2718,7 @@ function registerTools(server, storage, masking, git, staleness, recorder, ctxoR
2723
2718
  {
2724
2719
  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.",
2725
2720
  inputSchema: {
2726
- symbolId: z15.string().min(1).describe("The symbol ID (format: file::name::kind)"),
2721
+ symbolId: z15.string().min(1).describe(`The symbol ID (${SYMBOL_ID_FORMAT})`),
2727
2722
  maxCommits: z15.number().int().min(1).optional().describe("Limit commit history to N most recent commits")
2728
2723
  },
2729
2724
  annotations: toolAnnotations
@@ -2741,13 +2736,13 @@ function registerTools(server, storage, masking, git, staleness, recorder, ctxoR
2741
2736
  },
2742
2737
  (args) => changeIntelligenceHandler(args)
2743
2738
  );
2744
- const blastRadiusHandler = withRecording("get_blast_radius", handleGetBlastRadius(storage, masking, staleness, ctxoRoot, git, clusterLabelMasker), recorder);
2739
+ const blastRadiusHandler = withRecording("get_blast_radius", handleGetBlastRadius(storage, masking, staleness, ctxoRoot, git, clusterLabelMasker, coChange), recorder);
2745
2740
  server.registerTool(
2746
2741
  "get_blast_radius",
2747
2742
  {
2748
- 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.",
2743
+ description: "BEFORE modifying any symbol (a function, class, method or type, or a SQL table, view or procedure), call this to understand impact. Returns every symbol that depends on the target, directly or transitively, with risk scores that fall with depth. Each dependent is in the tier of its strongest edge: confirmed (`calls`, `extends`, `implements` and the data edges `reads`, `writes`, `executes`), likely (co-changed or `uses`), and potential (`imports`). When co-change history exists, `coChangedFiles` lists up to 10 files that often change together with the target's file but have no dependency path to it (informational: not counted in the tiers or 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.",
2749
2744
  inputSchema: {
2750
- symbolId: z15.string().min(1).describe("The symbol ID (format: file::name::kind)"),
2745
+ symbolId: z15.string().min(1).describe(`The symbol ID (${SYMBOL_ID_FORMAT}). Every definition of a SQL object, and its name::kind id, has the same dependents`),
2751
2746
  confidence: z15.enum(["confirmed", "likely", "potential"]).optional().describe("Filter by confidence tier"),
2752
2747
  intent: z15.string().optional().describe('Filter impacted symbols by intent keywords (e.g., "test", "adapter")')
2753
2748
  },
@@ -2755,13 +2750,15 @@ function registerTools(server, storage, masking, git, staleness, recorder, ctxoR
2755
2750
  },
2756
2751
  (args) => blastRadiusHandler(args)
2757
2752
  );
2758
- const overlayHandler = withRecording("get_architectural_overlay", handleGetArchitecturalOverlay(storage, masking, staleness, git, clusterLabelMasker), recorder);
2753
+ const overlayHandler = withRecording("get_architectural_overlay", handleGetArchitecturalOverlay(storage, masking, staleness, git, clusterLabelMasker, new ContextsViewReader(ctxoRoot)), recorder);
2759
2754
  server.registerTool(
2760
2755
  "get_architectural_overlay",
2761
2756
  {
2762
- 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.",
2757
+ description: 'Get the project architecture map. mode "both" (default) returns the regex layer map (Domain, Infrastructure, Adapter, Test, Composition, Configuration) plus data-driven Louvain communities; "regex" or "communities" returns one of them. mode "contexts" returns candidate bounded contexts computed by `ctxo index`: file-level clusters of dependencies and co-change with hub files damped. The summary lists every candidate with label, size, cohesion and top terms, plus hubs and name collisions (type names whose shapes differ between candidates). Use this when onboarding to a new codebase, validating layer boundaries, or preparing a context map. For symbol-level analysis, use get_logic_slice or get_blast_radius. Pass candidate: <id> for one candidate in full: its files, cross-candidate edges, co-change pairs, hubs and collisions. The summary also gives each candidate\'s sharedTableCount and the totals dataResourcesTotal, sharedResourcesTotal and externalResourcesTotal. With candidate: <id>, the detail adds sharedTables (SQL tables and views that files of this candidate and at least one other read or write, with access modes per candidate; up to the first 15 by name, each with up to 10 accesses) and the candidate\'s own dataResources (up to the first 15 by name, each with its access modes, writer count, the owning candidate when only one writes it, and an external flag for resources that no .sql file defines). Both lists hold fewer entries when the response limit requires; sharedTableCount and dataResourcesTotal keep the full counts, and ctxo contexts --json has the full lists.',
2763
2758
  inputSchema: {
2764
- layer: z15.string().optional().describe("Filter by specific layer name")
2759
+ layer: z15.string().optional().describe("Filter by specific layer name"),
2760
+ mode: z15.enum(OVERLAY_MODES).optional().describe('View to return: "both" (default) layers plus communities, "regex" layer map, "communities" Louvain clusters, "contexts" candidate contexts (file level)'),
2761
+ candidate: z15.number().int().positive().optional().describe('With mode "contexts": the id of one candidate to return in full (files, terms, cross edges, co-change pairs, its hubs and collisions); omit for the summary of all candidates')
2765
2762
  },
2766
2763
  annotations: toolAnnotations
2767
2764
  },
@@ -2771,7 +2768,7 @@ function registerTools(server, storage, masking, git, staleness, recorder, ctxoR
2771
2768
  server.registerTool(
2772
2769
  "find_dead_code",
2773
2770
  {
2774
- 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.',
2771
+ 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. Symbols in .sql files are never reported. 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.',
2775
2772
  inputSchema: {
2776
2773
  includeTests: z15.boolean().optional().default(false).describe("Include test files in analysis (default: exclude)"),
2777
2774
  intent: z15.string().optional().describe('Filter dead code results by intent keywords (e.g., "adapter", "function")')
@@ -2780,13 +2777,13 @@ function registerTools(server, storage, masking, git, staleness, recorder, ctxoR
2780
2777
  },
2781
2778
  (args) => deadCodeHandler(args)
2782
2779
  );
2783
- const contextForTaskHandler = withRecording("get_context_for_task", handleGetContextForTask(storage, masking, staleness, ctxoRoot), recorder);
2780
+ const contextForTaskHandler = withRecording("get_context_for_task", handleGetContextForTask(storage, masking, staleness, ctxoRoot, coChange), recorder);
2784
2781
  server.registerTool(
2785
2782
  "get_context_for_task",
2786
2783
  {
2787
- 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.',
2784
+ description: 'Get task-optimized context for a symbol based on what you are about to do. Specify taskType: "fix" (bug investigation - includes history + anti-patterns), "extend" (add feature - includes deps + blast radius), "refactor" (restructure - includes importers + complexity), or "understand" (learn - includes full slice + architecture). Context entries that depend on the symbol (its blast radius) carry an optional `confidence` tier: confirmed, likely or potential, where co-change history can raise potential to likely. This is the BEST starting point when you know both the symbol and your intent.',
2788
2785
  inputSchema: {
2789
- symbolId: z15.string().min(1).describe("The symbol ID (format: file::name::kind)"),
2786
+ symbolId: z15.string().min(1).describe(`The symbol ID (${SYMBOL_ID_FORMAT})`),
2790
2787
  taskType: z15.enum(["fix", "extend", "refactor", "understand"]).describe("Task type determines which context is most relevant"),
2791
2788
  tokenBudget: z15.number().optional().default(4e3).describe("Max tokens for context (default 4000)")
2792
2789
  },
@@ -2802,7 +2799,8 @@ function registerTools(server, storage, masking, git, staleness, recorder, ctxoR
2802
2799
  inputSchema: {
2803
2800
  query: z15.string().min(1).describe("Search query (matches symbol names)"),
2804
2801
  tokenBudget: z15.number().optional().default(4e3).describe("Max tokens for results (default 4000)"),
2805
- strategy: z15.enum(["combined", "dependency", "importance"]).optional().default("combined").describe("Ranking strategy")
2802
+ strategy: z15.enum(["combined", "dependency", "importance"]).optional().default("combined").describe("Ranking strategy"),
2803
+ searchMode: z15.enum(["fts", "legacy"]).optional().default("fts").describe('"fts" (default): BM25 ranking with camelCase splitting, trigram fallback and typo correction; "legacy": substring matching; strategy "importance" always uses legacy matching')
2806
2804
  },
2807
2805
  annotations: toolAnnotations
2808
2806
  },
@@ -2815,9 +2813,10 @@ function registerTools(server, storage, masking, git, staleness, recorder, ctxoR
2815
2813
  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.",
2816
2814
  inputSchema: {
2817
2815
  pattern: z15.string().min(1).describe("Search pattern (substring or regex)"),
2818
- kind: z15.enum(["function", "class", "interface", "method", "variable", "type"]).optional().describe("Filter by symbol kind"),
2816
+ kind: SymbolKindSchema.optional().describe("Filter by symbol kind (table, view and procedure come from SQL plugins)"),
2819
2817
  filePattern: z15.string().optional().describe("Filter by file path substring"),
2820
- limit: z15.number().int().min(1).max(100).optional().default(25).describe("Max results (default 25)")
2818
+ limit: z15.number().int().min(1).max(100).optional().default(25).describe("Max results (default 25)"),
2819
+ mode: z15.enum(["regex", "fts"]).optional().default("regex").describe('"regex" (default): case-insensitive regex or substring match on names; "fts": BM25 relevance search')
2821
2820
  },
2822
2821
  annotations: toolAnnotations
2823
2822
  },
@@ -2842,8 +2841,8 @@ function registerTools(server, storage, masking, git, staleness, recorder, ctxoR
2842
2841
  {
2843
2842
  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.',
2844
2843
  inputSchema: {
2845
- symbolId: z15.string().min(1).describe("The symbol ID (format: file::name::kind)"),
2846
- edgeKinds: z15.array(z15.enum(["imports", "calls", "extends", "implements", "uses"])).optional().describe("Filter by edge kinds"),
2844
+ symbolId: z15.string().min(1).describe(`The symbol ID (${SYMBOL_ID_FORMAT}). Every definition of a SQL object, and its name::kind id, has the same importers`),
2845
+ edgeKinds: z15.array(EdgeKindSchema).optional().describe("Filter by edge kinds (reads, writes and executes are data access)"),
2847
2846
  transitive: z15.boolean().optional().default(false).describe("Follow transitive reverse edges (default false)"),
2848
2847
  maxDepth: z15.number().int().min(1).max(10).optional().default(5).describe("Max BFS depth for transitive mode (default 5)"),
2849
2848
  intent: z15.string().optional().describe('Filter importers by intent keywords (e.g., "test", "core", "adapter")')
@@ -2872,7 +2871,7 @@ function registerTools(server, storage, masking, git, staleness, recorder, ctxoR
2872
2871
  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.",
2873
2872
  inputSchema: {
2874
2873
  limit: z15.number().int().min(1).max(200).optional().default(25).describe("Max results (default 25)"),
2875
- kind: z15.enum(["function", "class", "interface", "method", "variable", "type"]).optional().describe("Filter by symbol kind"),
2874
+ kind: SymbolKindSchema.optional().describe("Filter by symbol kind (table, view and procedure come from SQL plugins)"),
2876
2875
  filePattern: z15.string().optional().describe("Filter by file path substring"),
2877
2876
  damping: z15.number().min(0).max(1).optional().default(0.85).describe("PageRank damping factor (default 0.85)")
2878
2877
  },
@@ -2880,7 +2879,7 @@ function registerTools(server, storage, masking, git, staleness, recorder, ctxoR
2880
2879
  },
2881
2880
  (args) => symbolImportanceHandler(args)
2882
2881
  );
2883
- const prImpactHandler = withRecording("get_pr_impact", handleGetPrImpact(storage, git, masking, staleness, ctxoRoot, clusterLabelMasker), recorder);
2882
+ const prImpactHandler = withRecording("get_pr_impact", handleGetPrImpact(storage, git, masking, staleness, ctxoRoot, clusterLabelMasker, coChange), recorder);
2884
2883
  server.registerTool(
2885
2884
  "get_pr_impact",
2886
2885
  {