ast-mcp-server 0.13.1 → 0.14.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.
@@ -12,6 +12,41 @@ export const RELATIONSHIP_EDGE_KINDS = Object.freeze([
12
12
  "call",
13
13
  "contains",
14
14
  ]);
15
+ export const RELATIONSHIP_COVERAGE_STATUSES = Object.freeze([
16
+ "not_applicable",
17
+ "completed",
18
+ "unsupported",
19
+ "unfinished",
20
+ ]);
21
+ const COVERAGE_STATUS_PRECEDENCE = {
22
+ not_applicable: 0,
23
+ completed: 1,
24
+ unsupported: 2,
25
+ unfinished: 3,
26
+ };
27
+ function coverageKey(entry) {
28
+ return `${entry.kind}:${entry.direction}:${entry.endpoint_class}`;
29
+ }
30
+ export function canonicalRelationshipCoverage(coverage) {
31
+ const kindOrder = new Map(RELATIONSHIP_EDGE_KINDS.map((kind, index) => [kind, index]));
32
+ return [...coverage].sort((left, right) => kindOrder.get(left.kind) - kindOrder.get(right.kind) ||
33
+ left.direction.localeCompare(right.direction) ||
34
+ left.endpoint_class.localeCompare(right.endpoint_class));
35
+ }
36
+ export function mergeRelationshipCoverage(...observations) {
37
+ const merged = new Map();
38
+ for (const entry of observations.flat()) {
39
+ const current = merged.get(coverageKey(entry));
40
+ if (!current ||
41
+ COVERAGE_STATUS_PRECEDENCE[entry.status] > COVERAGE_STATUS_PRECEDENCE[current.status]) {
42
+ merged.set(coverageKey(entry), entry);
43
+ }
44
+ }
45
+ return canonicalRelationshipCoverage([...merged.values()]);
46
+ }
47
+ export function isRelationshipCoverageComplete(coverage) {
48
+ return coverage.every(({ status }) => status === "completed" || status === "not_applicable");
49
+ }
15
50
  export const RELATIONSHIP_PROVENANCES = Object.freeze(["compiler", "syntax", "heuristic"]);
16
51
  export const RELATIONSHIP_CONFIDENCES = Object.freeze(["exact", "high", "medium", "low"]);
17
52
  export const RELATIONSHIP_RESOLUTIONS = Object.freeze([
@@ -268,6 +303,41 @@ function addModuleEdges(state, requestContext) {
268
303
  }
269
304
  }
270
305
  }
306
+ function symbolSupportsCallDirection(node, direction) {
307
+ if (!node)
308
+ return true;
309
+ if (direction === "incoming") {
310
+ return (Node.isFunctionDeclaration(node) ||
311
+ Node.isMethodDeclaration(node) ||
312
+ Node.isConstructorDeclaration(node) ||
313
+ Node.isClassDeclaration(node) ||
314
+ Node.isVariableDeclaration(node));
315
+ }
316
+ return (Node.isFunctionDeclaration(node) ||
317
+ Node.isMethodDeclaration(node) ||
318
+ Node.isConstructorDeclaration(node) ||
319
+ Node.isGetAccessorDeclaration(node) ||
320
+ Node.isSetAccessorDeclaration(node));
321
+ }
322
+ export function relationshipCoverageApplicability(kind, direction, endpointClass, node) {
323
+ if (kind === "contains")
324
+ return true;
325
+ if (endpointClass === "module")
326
+ return kind === "import" || kind === "export";
327
+ if (kind === "reference")
328
+ return true;
329
+ if (kind === "import" || kind === "export")
330
+ return direction === "incoming";
331
+ if (kind === "call")
332
+ return symbolSupportsCallDirection(node, direction);
333
+ if (kind === "extends") {
334
+ return !node || Node.isClassDeclaration(node) || Node.isInterfaceDeclaration(node);
335
+ }
336
+ return (kind === "implements" &&
337
+ (!node ||
338
+ Node.isClassDeclaration(node) ||
339
+ (direction === "incoming" && Node.isInterfaceDeclaration(node))));
340
+ }
271
341
  const DEFAULT_COMPILER_RELATIONSHIP_WORK_ITEMS = 100_000;
272
342
  class ScopedRelationshipLimitReached extends Error {
273
343
  constructor() {
@@ -945,6 +1015,221 @@ function addScopedIncomingExports(state, edges, target) {
945
1015
  });
946
1016
  }
947
1017
  }
1018
+ function scopedInvocationForReference(reference) {
1019
+ for (const ancestor of reference.getAncestors()) {
1020
+ const expression = callLikeExpression(ancestor);
1021
+ if (!expression)
1022
+ continue;
1023
+ if (reference.getStart() < expression.getStart() || reference.getEnd() > expression.getEnd()) {
1024
+ return undefined;
1025
+ }
1026
+ return { invocation: ancestor, direct: unwrapInvocationExpression(expression) === reference };
1027
+ }
1028
+ return undefined;
1029
+ }
1030
+ function scopedLocatedCallTarget(state, collector, declaration) {
1031
+ if (Node.isParameterDeclaration(declaration) || Node.isMethodSignature(declaration))
1032
+ return;
1033
+ const sourceFile = declaration.getSourceFile();
1034
+ if (!isProjectScopedFile(state.projectRoot, sourceFile.getFilePath()))
1035
+ return;
1036
+ const targetNode = Node.isConstructorDeclaration(declaration)
1037
+ ? declaration.getFirstAncestorByKind(SyntaxKind.ClassDeclaration)
1038
+ : declaration;
1039
+ if (!targetNode)
1040
+ return;
1041
+ let located;
1042
+ forEachLocatedSymbol(sourceFile, (candidate) => {
1043
+ consumeScopedWork(state, collector);
1044
+ if (candidate.node !== targetNode)
1045
+ return;
1046
+ located = candidate;
1047
+ return false;
1048
+ });
1049
+ return located;
1050
+ }
1051
+ function computedCallAuthority(projectRoot, invocation) {
1052
+ const expression = callLikeExpression(invocation);
1053
+ if (!expression)
1054
+ return { state: "not_computed", workItems: 0 };
1055
+ const invoked = unwrapInvocationExpression(expression);
1056
+ if (!Node.isElementAccessExpression(invoked))
1057
+ return { state: "not_computed", workItems: 0 };
1058
+ const argument = invoked.getArgumentExpression();
1059
+ const keyTypes = argument
1060
+ ? argument.getType().isUnion()
1061
+ ? argument.getType().getUnionTypes()
1062
+ : [argument.getType()]
1063
+ : [];
1064
+ const keys = keyTypes.flatMap((type) => type.isStringLiteral() && typeof type.getLiteralValue() === "string"
1065
+ ? [type.getLiteralValue()]
1066
+ : []);
1067
+ const receiverType = invoked.getExpression().getType();
1068
+ const receiverTypes = receiverType.isUnion() ? receiverType.getUnionTypes() : [receiverType];
1069
+ const workItems = keyTypes.length + receiverTypes.length;
1070
+ if (keys.length === 1 && keyTypes.length === 1 && receiverTypes.length === 1) {
1071
+ return { state: "single_key_eligible", workItems };
1072
+ }
1073
+ const alternatives = new Map();
1074
+ for (const receiver of receiverTypes) {
1075
+ for (const key of keys) {
1076
+ for (const declaration of declarationsForSymbol(receiver.getProperty(key))) {
1077
+ const target = locatedCallTarget(projectRoot, declaration);
1078
+ if (target)
1079
+ alternatives.set(symbolEndpoint(target, projectRoot).selector, target);
1080
+ }
1081
+ }
1082
+ }
1083
+ const signature = invocation
1084
+ .getSourceFile()
1085
+ .getProject()
1086
+ .getTypeChecker()
1087
+ .getResolvedSignature(invocation);
1088
+ const selected = signature?.getDeclaration();
1089
+ const selectedTarget = selected ? locatedCallTarget(projectRoot, selected) : undefined;
1090
+ if (selectedTarget) {
1091
+ alternatives.set(symbolEndpoint(selectedTarget, projectRoot).selector, selectedTarget);
1092
+ }
1093
+ return {
1094
+ state: "unfinished",
1095
+ alternatives: [...alternatives.values()].sort((left, right) => symbolEndpoint(left, projectRoot).selector.localeCompare(symbolEndpoint(right, projectRoot).selector)),
1096
+ workItems: workItems + alternatives.size,
1097
+ };
1098
+ }
1099
+ function scopedComputedCallSites(state, collector) {
1100
+ if (state.computedCallSites) {
1101
+ reserveScopedWork(state, collector, state.computedCallSites.length);
1102
+ return state.computedCallSites;
1103
+ }
1104
+ const sites = [];
1105
+ for (const sourceFile of scopedSourceFiles(state, collector)) {
1106
+ sourceFile.forEachDescendant((node) => {
1107
+ consumeScopedWork(state, collector);
1108
+ const expression = callLikeExpression(node);
1109
+ if (expression && Node.isElementAccessExpression(unwrapInvocationExpression(expression))) {
1110
+ sites.push(node);
1111
+ }
1112
+ });
1113
+ }
1114
+ state.computedCallSites = sites;
1115
+ return sites;
1116
+ }
1117
+ function scopedDirectCallTarget(state, collector, invocation) {
1118
+ const expression = callLikeExpression(invocation);
1119
+ if (!expression)
1120
+ return;
1121
+ const invoked = unwrapInvocationExpression(expression);
1122
+ if (!Node.isIdentifier(invoked))
1123
+ return;
1124
+ const targets = new Map();
1125
+ for (const declaration of scopedDeclarationsForSymbol(state, collector, invoked.getSymbol())) {
1126
+ const target = scopedLocatedCallTarget(state, collector, declaration);
1127
+ if (target)
1128
+ targets.set(endpointKey(symbolEndpoint(target, state.projectRoot)), target);
1129
+ }
1130
+ return targets.size === 1 ? [...targets.values()][0] : undefined;
1131
+ }
1132
+ function scopedReferenceFlowsToUncertainCall(state, collector, reference) {
1133
+ const declaration = reference.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
1134
+ const initializer = declaration?.getInitializer();
1135
+ const name = declaration?.getNameNode();
1136
+ if (!initializer ||
1137
+ !name ||
1138
+ !Node.isIdentifier(name) ||
1139
+ reference.getStart() < initializer.getStart() ||
1140
+ reference.getEnd() > initializer.getEnd()) {
1141
+ return false;
1142
+ }
1143
+ consumeScopedWork(state, collector);
1144
+ const carrierReferences = name.findReferencesAsNodes();
1145
+ reserveScopedWork(state, collector, carrierReferences.length);
1146
+ return carrierReferences.some((carrierReference) => Boolean(scopedInvocationForReference(carrierReference)));
1147
+ }
1148
+ function addScopedIncomingCalls(state, collector, target) {
1149
+ if (!Node.isReferenceFindable(target.node))
1150
+ return;
1151
+ for (const invocation of scopedComputedCallSites(state, collector)) {
1152
+ const authority = computedCallAuthority(state.projectRoot, invocation);
1153
+ reserveScopedWork(state, collector, authority.workItems);
1154
+ if (authority.state === "unfinished" &&
1155
+ authority.alternatives.some((alternative) => alternative.node === target.node)) {
1156
+ collector.semanticUnfinished = true;
1157
+ }
1158
+ }
1159
+ consumeScopedWork(state, collector);
1160
+ const references = target.node.findReferencesAsNodes();
1161
+ reserveScopedWork(state, collector, references.length);
1162
+ references.sort((left, right) => left.getSourceFile().getFilePath().localeCompare(right.getSourceFile().getFilePath()) ||
1163
+ left.getStart() - right.getStart());
1164
+ const candidates = createScopedCandidateSet(collector);
1165
+ for (const reference of references) {
1166
+ const site = scopedInvocationForReference(reference);
1167
+ if (!site) {
1168
+ if (scopedReferenceFlowsToUncertainCall(state, collector, reference)) {
1169
+ collector.unfinished = true;
1170
+ }
1171
+ continue;
1172
+ }
1173
+ const authority = computedCallAuthority(state.projectRoot, site.invocation);
1174
+ reserveScopedWork(state, collector, authority.workItems);
1175
+ if (authority.state === "unfinished") {
1176
+ collector.semanticUnfinished = true;
1177
+ continue;
1178
+ }
1179
+ const source = scopedContainingSymbol(state, collector, reference.getSourceFile(), site.invocation);
1180
+ const resolved = site.direct
1181
+ ? scopedDirectCallTarget(state, collector, site.invocation)
1182
+ : undefined;
1183
+ if (!source || !resolved || resolved.node !== target.node) {
1184
+ collector.unfinished = true;
1185
+ continue;
1186
+ }
1187
+ candidates.push(createScopedEdge(state, symbolEndpoint(source, state.projectRoot), symbolEndpoint(target, state.projectRoot), "call"));
1188
+ }
1189
+ addScopedCandidates(collector, candidates);
1190
+ }
1191
+ function isNestedNamedCallOwner(node) {
1192
+ if (Node.isFunctionDeclaration(node) ||
1193
+ Node.isClassDeclaration(node) ||
1194
+ Node.isMethodDeclaration(node) ||
1195
+ Node.isConstructorDeclaration(node) ||
1196
+ Node.isGetAccessorDeclaration(node) ||
1197
+ Node.isSetAccessorDeclaration(node)) {
1198
+ return true;
1199
+ }
1200
+ if (!Node.isArrowFunction(node) && !Node.isFunctionExpression(node))
1201
+ return false;
1202
+ const parent = node.getParent();
1203
+ return Boolean(parent &&
1204
+ (Node.isVariableDeclaration(parent) ||
1205
+ Node.isPropertyDeclaration(parent) ||
1206
+ Node.isPropertyAssignment(parent)));
1207
+ }
1208
+ function addScopedOutgoingCalls(state, collector, source) {
1209
+ const candidates = createScopedCandidateSet(collector);
1210
+ source.node.forEachDescendant((node, traversal) => {
1211
+ consumeScopedWork(state, collector);
1212
+ if (isNestedNamedCallOwner(node)) {
1213
+ traversal.skip();
1214
+ return;
1215
+ }
1216
+ if (!callLikeExpression(node))
1217
+ return;
1218
+ const authority = computedCallAuthority(state.projectRoot, node);
1219
+ reserveScopedWork(state, collector, authority.workItems);
1220
+ if (authority.state === "unfinished") {
1221
+ collector.semanticUnfinished = true;
1222
+ return;
1223
+ }
1224
+ const target = scopedDirectCallTarget(state, collector, node);
1225
+ if (!target) {
1226
+ collector.unfinished = true;
1227
+ return;
1228
+ }
1229
+ candidates.push(createScopedEdge(state, symbolEndpoint(source, state.projectRoot), symbolEndpoint(target, state.projectRoot), "call"));
1230
+ });
1231
+ addScopedCandidates(collector, candidates);
1232
+ }
948
1233
  function endpointKey(endpoint) {
949
1234
  return `${endpoint.file}\u0000${endpoint.symbol_path}\u0000${endpoint.selector}`;
950
1235
  }
@@ -966,6 +1251,45 @@ function addScopedIncomingModuleEdges(state, edges) {
966
1251
  addScopedModuleEdges(state, edges, sourceFile);
967
1252
  }
968
1253
  }
1254
+ function requestedCoverageDirections(direction) {
1255
+ return direction === "both" ? ["incoming", "outgoing"] : [direction];
1256
+ }
1257
+ function initialRelationshipCoverage(kinds, direction, endpointClass, node) {
1258
+ return canonicalRelationshipCoverage([...kinds].flatMap((kind) => requestedCoverageDirections(direction).map((coverageDirection) => ({
1259
+ kind,
1260
+ direction: coverageDirection,
1261
+ endpoint_class: endpointClass,
1262
+ status: relationshipCoverageApplicability(kind, coverageDirection, endpointClass, node)
1263
+ ? "unsupported"
1264
+ : "not_applicable",
1265
+ }))));
1266
+ }
1267
+ function relationshipWork(budget, maxWorkItems) {
1268
+ return {
1269
+ work_items: budget.consumed,
1270
+ max_work_items: maxWorkItems,
1271
+ work_limit_reached: budget.limitReached,
1272
+ };
1273
+ }
1274
+ function hasScopedRelationshipProducer(entry) {
1275
+ if (entry.kind === "contains")
1276
+ return false;
1277
+ if (entry.kind === "call")
1278
+ return entry.endpoint_class === "symbol";
1279
+ if (entry.endpoint_class === "module") {
1280
+ return entry.kind === "import" || entry.kind === "export";
1281
+ }
1282
+ if (entry.kind === "reference")
1283
+ return true;
1284
+ if (entry.kind === "import" || entry.kind === "export")
1285
+ return entry.direction === "incoming";
1286
+ return entry.kind === "extends" || entry.kind === "implements";
1287
+ }
1288
+ function resolveProducerCoverage(coverage, interrupted) {
1289
+ return coverage.map((entry) => entry.status === "unsupported" && hasScopedRelationshipProducer(entry)
1290
+ ? { ...entry, status: interrupted ? "unfinished" : "completed" }
1291
+ : entry);
1292
+ }
969
1293
  export function createCompilerRelationshipResolver(project, projectRoot, freshness, requestContext = NO_REQUEST_CONTEXT) {
970
1294
  requestContext.checkpoint();
971
1295
  const state = {
@@ -987,6 +1311,8 @@ export function createCompilerRelationshipResolver(project, projectRoot, freshne
987
1311
  }
988
1312
  const direction = assertEnum(query.direction, ["incoming", "outgoing", "both"], "direction");
989
1313
  const relationshipKinds = new Set(query.relationship_kinds.map((kind) => assertEnum(kind, RELATIONSHIP_EDGE_KINDS, "edge kind")));
1314
+ const endpointClass = endpoint.symbol_path === "<module>" ? "module" : "symbol";
1315
+ const initialCoverage = initialRelationshipCoverage(relationshipKinds, direction, endpointClass);
990
1316
  const workBudget = {
991
1317
  remaining: maxWorkItems,
992
1318
  consumed: 0,
@@ -1007,6 +1333,8 @@ export function createCompilerRelationshipResolver(project, projectRoot, freshne
1007
1333
  throw error;
1008
1334
  return {
1009
1335
  edges: [],
1336
+ coverage: resolveProducerCoverage(initialCoverage, true),
1337
+ work: relationshipWork(workBudget, maxWorkItems),
1010
1338
  incomplete: true,
1011
1339
  work_items: workBudget.consumed,
1012
1340
  work_limit_reached: true,
@@ -1028,6 +1356,7 @@ export function createCompilerRelationshipResolver(project, projectRoot, freshne
1028
1356
  excludedRelationshipIds,
1029
1357
  workBudget,
1030
1358
  excludedNeighbors: false,
1359
+ unfinished: false,
1031
1360
  };
1032
1361
  let located;
1033
1362
  try {
@@ -1038,16 +1367,37 @@ export function createCompilerRelationshipResolver(project, projectRoot, freshne
1038
1367
  throw error;
1039
1368
  return {
1040
1369
  edges: [],
1370
+ coverage: resolveProducerCoverage(initialCoverage, true),
1371
+ work: relationshipWork(workBudget, maxWorkItems),
1041
1372
  incomplete: true,
1042
1373
  work_items: workBudget.consumed,
1043
1374
  work_limit_reached: true,
1044
1375
  excluded_neighbors: false,
1045
1376
  };
1046
1377
  }
1378
+ const coverage = initialRelationshipCoverage(relationshipKinds, direction, endpointClass, located.symbol?.node);
1047
1379
  const outgoing = direction === "outgoing" || direction === "both";
1048
1380
  const incoming = direction === "incoming" || direction === "both";
1049
1381
  const producers = [];
1050
1382
  if (located.symbol) {
1383
+ if (incoming &&
1384
+ relationshipKinds.has("call") &&
1385
+ relationshipCoverageApplicability("call", "incoming", "symbol", located.symbol.node)) {
1386
+ producers.push({
1387
+ kinds: ["call"],
1388
+ callDirection: "incoming",
1389
+ collect: (collector) => addScopedIncomingCalls(state, collector, located.symbol),
1390
+ });
1391
+ }
1392
+ if (outgoing &&
1393
+ relationshipKinds.has("call") &&
1394
+ relationshipCoverageApplicability("call", "outgoing", "symbol", located.symbol.node)) {
1395
+ producers.push({
1396
+ kinds: ["call"],
1397
+ callDirection: "outgoing",
1398
+ collect: (collector) => addScopedOutgoingCalls(state, collector, located.symbol),
1399
+ });
1400
+ }
1051
1401
  if (outgoing && (relationshipKinds.has("extends") || relationshipKinds.has("implements"))) {
1052
1402
  producers.push({
1053
1403
  kinds: ["extends", "implements"],
@@ -1113,7 +1463,9 @@ export function createCompilerRelationshipResolver(project, projectRoot, freshne
1113
1463
  }
1114
1464
  }
1115
1465
  const merged = new Map();
1466
+ const callCoverage = new Map(producers.flatMap((producer) => producer.callDirection ? [[producer.callDirection, "unfinished"]] : []));
1116
1467
  let incomplete = false;
1468
+ let boundedIncomplete = false;
1117
1469
  let excludedNeighbors = false;
1118
1470
  for (const producer of producers) {
1119
1471
  const collector = {
@@ -1128,7 +1480,9 @@ export function createCompilerRelationshipResolver(project, projectRoot, freshne
1128
1480
  excludedRelationshipIds,
1129
1481
  workBudget,
1130
1482
  excludedNeighbors: false,
1483
+ unfinished: false,
1131
1484
  };
1485
+ let interrupted = false;
1132
1486
  try {
1133
1487
  producer.collect(collector);
1134
1488
  }
@@ -1137,8 +1491,18 @@ export function createCompilerRelationshipResolver(project, projectRoot, freshne
1137
1491
  !(error instanceof ScopedRelationshipWorkLimitReached)) {
1138
1492
  throw error;
1139
1493
  }
1494
+ interrupted = true;
1495
+ boundedIncomplete = true;
1140
1496
  incomplete = true;
1141
1497
  }
1498
+ if (collector.unfinished)
1499
+ incomplete = true;
1500
+ if (producer.callDirection &&
1501
+ !interrupted &&
1502
+ !collector.unfinished &&
1503
+ !collector.semanticUnfinished) {
1504
+ callCoverage.set(producer.callDirection, "completed");
1505
+ }
1142
1506
  for (const edge of collector.edges.values())
1143
1507
  merged.set(edge.relationship_id, edge);
1144
1508
  excludedNeighbors ||= collector.excludedNeighbors;
@@ -1146,11 +1510,18 @@ export function createCompilerRelationshipResolver(project, projectRoot, freshne
1146
1510
  break;
1147
1511
  }
1148
1512
  const ordered = [...merged.values()].sort((left, right) => scopedNeighborOrder(key, left, right));
1149
- if (ordered.length > query.max_edges)
1513
+ if (ordered.length > query.max_edges) {
1150
1514
  incomplete = true;
1515
+ boundedIncomplete = true;
1516
+ }
1151
1517
  const selected = ordered.slice(0, query.max_edges);
1518
+ const resolvedCoverage = resolveProducerCoverage(coverage, boundedIncomplete || workBudget.limitReached).map((entry) => entry.kind === "call" && callCoverage.has(entry.direction)
1519
+ ? { ...entry, status: callCoverage.get(entry.direction) }
1520
+ : entry);
1152
1521
  return {
1153
1522
  edges: selected.sort((left, right) => left.relationship_id.localeCompare(right.relationship_id)),
1523
+ coverage: resolvedCoverage,
1524
+ work: relationshipWork(workBudget, maxWorkItems),
1154
1525
  incomplete,
1155
1526
  work_items: workBudget.consumed,
1156
1527
  work_limit_reached: workBudget.limitReached,
@@ -1203,6 +1574,7 @@ export function collectCompilerCallRelationships(project, projectRoot, freshness
1203
1574
  const normalizedFreshness = normalizeFreshness(freshness);
1204
1575
  const checker = project.getTypeChecker();
1205
1576
  const edges = new Map();
1577
+ const unfinishedGaps = [];
1206
1578
  let workItems = 0;
1207
1579
  let workLimitReached = false;
1208
1580
  for (const sourceFile of project
@@ -1222,6 +1594,20 @@ export function collectCompilerCallRelationships(project, projectRoot, freshness
1222
1594
  const caller = containingSymbol(sourceFile, node);
1223
1595
  if (!caller)
1224
1596
  return;
1597
+ const computedAuthority = computedCallAuthority(projectRoot, node);
1598
+ workItems += computedAuthority.workItems;
1599
+ if (workItems > maxWorkItems) {
1600
+ workLimitReached = true;
1601
+ traversal.stop();
1602
+ return;
1603
+ }
1604
+ if (computedAuthority.state === "unfinished") {
1605
+ unfinishedGaps.push({
1606
+ source: symbolEndpoint(caller, projectRoot),
1607
+ alternatives: computedAuthority.alternatives.map((target) => symbolEndpoint(target, projectRoot)),
1608
+ });
1609
+ return;
1610
+ }
1225
1611
  const signature = checker.getResolvedSignature(node);
1226
1612
  const implicitConstructSignatures = Node.isNewExpression(node)
1227
1613
  ? invoked.getType().getConstructSignatures()
@@ -1231,6 +1617,7 @@ export function collectCompilerCallRelationships(project, projectRoot, freshness
1231
1617
  const signatureDeclaration = signature?.getDeclaration();
1232
1618
  const declarations = declarationsForSymbol(invoked.getSymbol());
1233
1619
  const targets = new Map();
1620
+ let unfinished = false;
1234
1621
  for (const declaration of declarations.length > 0
1235
1622
  ? declarations
1236
1623
  : signatureDeclaration
@@ -1238,10 +1625,18 @@ export function collectCompilerCallRelationships(project, projectRoot, freshness
1238
1625
  : []) {
1239
1626
  const target = locatedCallTarget(projectRoot, declaration);
1240
1627
  if (target)
1241
- targets.set(symbolEndpoint(target, projectRoot).selector, target);
1628
+ targets.set(endpointKey(symbolEndpoint(target, projectRoot)), target);
1629
+ else
1630
+ unfinished = true;
1242
1631
  }
1243
- if (targets.size !== 1)
1632
+ // Dropping an unlocatable alternative does not establish a single exact target.
1633
+ if (unfinished || targets.size !== 1) {
1634
+ unfinishedGaps.push({
1635
+ source: symbolEndpoint(caller, projectRoot),
1636
+ alternatives: [...targets.values()].map((target) => symbolEndpoint(target, projectRoot)),
1637
+ });
1244
1638
  return;
1639
+ }
1245
1640
  const target = [...targets.values()][0];
1246
1641
  const edge = createRelationshipEdge({
1247
1642
  source: symbolEndpoint(caller, projectRoot),
@@ -1258,9 +1653,13 @@ export function collectCompilerCallRelationships(project, projectRoot, freshness
1258
1653
  break;
1259
1654
  }
1260
1655
  const ordered = [...edges.values()].sort((left, right) => left.relationship_id.localeCompare(right.relationship_id));
1656
+ const boundedIncomplete = workLimitReached || ordered.length > maxEdges;
1657
+ unfinishedGaps.sort((left, right) => endpointKey(left.source).localeCompare(endpointKey(right.source)));
1261
1658
  return Object.freeze({
1262
1659
  edges: Object.freeze(ordered.slice(0, maxEdges)),
1263
- incomplete: workLimitReached || ordered.length > maxEdges,
1660
+ unfinished_gaps: Object.freeze(unfinishedGaps),
1661
+ bounded_incomplete: boundedIncomplete,
1662
+ incomplete: boundedIncomplete || unfinishedGaps.length > 0,
1264
1663
  });
1265
1664
  }
1266
1665
  export function collectCompilerRelationships(project, projectRoot, freshness, requestContext = NO_REQUEST_CONTEXT) {
@@ -1,4 +1,14 @@
1
- import { assertExactImpactEvidence } from "./impact.js";
1
+ import { assertCompleteExactImpactEvidence } from "./impact.js";
2
+ import { isRelationshipCoverageComplete, } from "./relationships.js";
3
+ export const AFFECTED_TEST_RELATIONSHIP_KINDS = Object.freeze([
4
+ "reference",
5
+ "import",
6
+ "export",
7
+ "extends",
8
+ "implements",
9
+ "call",
10
+ ]);
11
+ const AFFECTED_TEST_RELATIONSHIP_KIND_SET = new Set(AFFECTED_TEST_RELATIONSHIP_KINDS);
2
12
  export const DEFAULT_TEST_FILE_PATTERNS = Object.freeze(["**/*.test.*", "**/*.spec.*"]);
3
13
  export const DEFAULT_TEST_DIRECTORIES = Object.freeze(["test", "tests", "__tests__"]);
4
14
  export const TEST_CANDIDATE_REASONS = Object.freeze([
@@ -156,8 +166,31 @@ function findPathToRoot(startKeys, rootKey, edges) {
156
166
  }
157
167
  return null;
158
168
  }
169
+ function assertCandidateImpactAuthority(impact) {
170
+ assertCompleteExactImpactEvidence(impact);
171
+ if (impact.direction !== "incoming" ||
172
+ impact.relationship_kinds.length !== AFFECTED_TEST_RELATIONSHIP_KINDS.length ||
173
+ !impact.relationship_kinds.every((kind, index) => kind === AFFECTED_TEST_RELATIONSHIP_KINDS[index])) {
174
+ throw new Error("Test candidates require the six-kind incoming impact scope.");
175
+ }
176
+ const coveredKinds = new Set(impact.coverage?.map((entry) => entry.kind));
177
+ if (!Array.isArray(impact.coverage) ||
178
+ coveredKinds.size !== AFFECTED_TEST_RELATIONSHIP_KINDS.length ||
179
+ !AFFECTED_TEST_RELATIONSHIP_KINDS.every((kind) => coveredKinds.has(kind)) ||
180
+ !impact.coverage.every((entry) => entry.direction === "incoming" &&
181
+ AFFECTED_TEST_RELATIONSHIP_KIND_SET.has(entry.kind) &&
182
+ (entry.status === "completed" || entry.status === "not_applicable")) ||
183
+ !isRelationshipCoverageComplete(impact.coverage)) {
184
+ throw new Error("Test candidates require complete six-kind incoming coverage.");
185
+ }
186
+ if (impact.work === undefined ||
187
+ impact.work.work_limit_reached ||
188
+ impact.work.work_items > impact.work.max_work_items) {
189
+ throw new Error("Test candidates require bounded impact work.");
190
+ }
191
+ }
159
192
  export function findTestCandidates(impact, conventions = {}) {
160
- assertExactImpactEvidence(impact);
193
+ assertCandidateImpactAuthority(impact);
161
194
  if (typeof impact.root !== "object" ||
162
195
  impact.root === null ||
163
196
  !Array.isArray(impact.nodes) ||
@@ -4,9 +4,10 @@ import { paginate, PaginationInputSchema, PaginationOutputSchema } from "../serv
4
4
  import { withProject } from "../services/project.js";
5
5
  import { PublicOperationalError } from "../services/public-errors.js";
6
6
  import { createRequestContext } from "../services/request-context.js";
7
- import { findTestCandidates, MAX_TEST_CANDIDATE_CONVENTION_ITEMS, MAX_TEST_CANDIDATE_CONVENTION_LENGTH, TEST_CANDIDATE_CONFIDENCES, TEST_CANDIDATE_REASONS, } from "../services/test-candidates.js";
8
- import { FreshnessSchema, RelationshipEdgeSchema, RelationshipEndpointSchema, } from "./relationship-schema.js";
7
+ import { AFFECTED_TEST_RELATIONSHIP_KINDS, findTestCandidates, MAX_TEST_CANDIDATE_CONVENTION_ITEMS, MAX_TEST_CANDIDATE_CONVENTION_LENGTH, TEST_CANDIDATE_CONFIDENCES, TEST_CANDIDATE_REASONS, } from "../services/test-candidates.js";
8
+ import { FreshnessSchema, RelationshipCoverageEntrySchema, RelationshipEdgeSchema, RelationshipEndpointSchema, RelationshipWorkSchema, } from "./relationship-schema.js";
9
9
  import { createToolErrorContext, errorResult, structuredResult } from "./result.js";
10
+ export { AFFECTED_TEST_RELATIONSHIP_KINDS } from "../services/test-candidates.js";
10
11
  const TOOL_NAME = "ast_find_test_candidates";
11
12
  const ConventionSchema = z
12
13
  .array(z.string().min(1).max(MAX_TEST_CANDIDATE_CONVENTION_LENGTH))
@@ -40,6 +41,16 @@ const FindTestCandidatesOutputSchema = z.object({
40
41
  compiler_authoritative: z.literal(true),
41
42
  root: RelationshipEndpointSchema,
42
43
  direction: z.literal("incoming"),
44
+ relationship_kinds: z.tuple([
45
+ z.literal("reference"),
46
+ z.literal("import"),
47
+ z.literal("export"),
48
+ z.literal("extends"),
49
+ z.literal("implements"),
50
+ z.literal("call"),
51
+ ]),
52
+ coverage: z.array(RelationshipCoverageEntrySchema),
53
+ work: RelationshipWorkSchema,
43
54
  candidates: z.array(TestCandidateSchema),
44
55
  ...PaginationOutputSchema,
45
56
  visited_nodes: z.number().int().min(0),
@@ -84,7 +95,14 @@ export function registerFindTestCandidates(server) {
84
95
  checked_at: context.status.lastSuccessfulSyncAt,
85
96
  };
86
97
  const root = resolveImpactRoot(context.project, context.projectRoot, { file_path, symbol_path }, operationContext);
87
- const impact = traverseCompilerImpact(context.project, context.projectRoot, root, freshness, { direction: "incoming", max_depth, max_nodes, max_edges }, operationContext);
98
+ const impact = traverseCompilerImpact(context.project, context.projectRoot, root, freshness, {
99
+ direction: "incoming",
100
+ relationship_kinds: AFFECTED_TEST_RELATIONSHIP_KINDS,
101
+ max_depth,
102
+ max_nodes,
103
+ max_edges,
104
+ authority: "semantic",
105
+ }, operationContext);
88
106
  if (impact.incomplete || impact.truncation.truncated) {
89
107
  throw new PublicOperationalError("INCOMPLETE_EVIDENCE", "Evidence is incomplete.");
90
108
  }
@@ -98,6 +116,9 @@ export function registerFindTestCandidates(server) {
98
116
  compiler_authoritative: true,
99
117
  root: impact.root,
100
118
  direction: "incoming",
119
+ relationship_kinds: AFFECTED_TEST_RELATIONSHIP_KINDS,
120
+ coverage: impact.coverage,
121
+ work: impact.work,
101
122
  candidates: page.items,
102
123
  offset: page.offset,
103
124
  limit: page.limit,
@@ -4,7 +4,7 @@ import { TRUNCATION_REASONS } from "../services/read-contracts.js";
4
4
  import { withProject } from "../services/project.js";
5
5
  import { createRequestContext } from "../services/request-context.js";
6
6
  import { RELATIONSHIP_EDGE_KINDS } from "../services/relationships.js";
7
- import { FreshnessSchema, RelationshipEdgeSchema, RelationshipEndpointSchema, } from "./relationship-schema.js";
7
+ import { FreshnessSchema, RelationshipCoverageEntrySchema, RelationshipEdgeSchema, RelationshipEndpointSchema, RelationshipWorkSchema, } from "./relationship-schema.js";
8
8
  import { createToolErrorContext, errorResult, formattedResult, ToolOutputFormatInputSchema, } from "./result.js";
9
9
  const TOOL_NAME = "ast_get_impact";
10
10
  const AstGetImpactInputSchema = z.object({
@@ -60,6 +60,9 @@ const ImpactOutputSchema = z.object({
60
60
  max_depth: z.number().int().min(0),
61
61
  max_nodes: z.number().int().positive(),
62
62
  max_edges: z.number().int().positive(),
63
+ coverage: z.array(RelationshipCoverageEntrySchema),
64
+ work: RelationshipWorkSchema,
65
+ proven_empty: z.boolean(),
63
66
  incomplete: z.boolean(),
64
67
  truncation: z.object({
65
68
  truncated: z.boolean(),
@@ -92,7 +95,14 @@ export function registerGetImpact(server) {
92
95
  checked_at: context.status.lastSuccessfulSyncAt,
93
96
  };
94
97
  const root = resolveImpactRoot(context.project, context.projectRoot, { file_path, symbol_path }, operationContext);
95
- const impact = traverseCompilerImpact(context.project, context.projectRoot, root, freshness, { direction, max_depth, max_nodes, max_edges, relationship_kinds }, operationContext);
98
+ const impact = traverseCompilerImpact(context.project, context.projectRoot, root, freshness, {
99
+ direction,
100
+ max_depth,
101
+ max_nodes,
102
+ max_edges,
103
+ relationship_kinds,
104
+ authority: "semantic",
105
+ }, operationContext);
96
106
  return {
97
107
  ...impact,
98
108
  freshness,