@ttsc/graph 0.19.3 → 0.20.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.
Files changed (82) hide show
  1. package/lib/TtscGraphApplication.js +3 -1
  2. package/lib/TtscGraphApplication.js.map +1 -1
  3. package/lib/bin.js +15 -3
  4. package/lib/bin.js.map +1 -1
  5. package/lib/index.d.ts +13 -2
  6. package/lib/index.js +24 -30
  7. package/lib/index.js.map +1 -1
  8. package/lib/launcherArgs.d.ts +24 -0
  9. package/lib/launcherArgs.js +136 -0
  10. package/lib/launcherArgs.js.map +1 -0
  11. package/lib/model/TtscGraphMemory.d.ts +9 -5
  12. package/lib/model/TtscGraphMemory.js +35 -58
  13. package/lib/model/TtscGraphMemory.js.map +1 -1
  14. package/lib/model/TtscGraphNodeId.d.ts +17 -0
  15. package/lib/model/TtscGraphNodeId.js +69 -0
  16. package/lib/model/TtscGraphNodeId.js.map +1 -0
  17. package/lib/model/TtscGraphSession.d.ts +23 -7
  18. package/lib/model/TtscGraphSession.js +402 -210
  19. package/lib/model/TtscGraphSession.js.map +1 -1
  20. package/lib/model/TtscGraphSourceReader.d.ts +27 -0
  21. package/lib/model/TtscGraphSourceReader.js +97 -0
  22. package/lib/model/TtscGraphSourceReader.js.map +1 -0
  23. package/lib/model/loadGraph.d.ts +4 -4
  24. package/lib/model/loadGraph.js +233 -168
  25. package/lib/model/loadGraph.js.map +1 -1
  26. package/lib/reduce.d.ts +4 -1
  27. package/lib/reduce.js +112 -29
  28. package/lib/reduce.js.map +1 -1
  29. package/lib/server/createServer.js +17 -6
  30. package/lib/server/createServer.js.map +1 -1
  31. package/lib/server/pathPolicy.d.ts +6 -0
  32. package/lib/server/pathPolicy.js +10 -1
  33. package/lib/server/pathPolicy.js.map +1 -1
  34. package/lib/server/resolveHandle.js +22 -18
  35. package/lib/server/resolveHandle.js.map +1 -1
  36. package/lib/server/resultAudit.d.ts +41 -20
  37. package/lib/server/resultAudit.js +62 -34
  38. package/lib/server/resultAudit.js.map +1 -1
  39. package/lib/server/resultNext.d.ts +5 -0
  40. package/lib/server/resultNext.js.map +1 -1
  41. package/lib/server/runDetails.d.ts +2 -2
  42. package/lib/server/runDetails.js +54 -108
  43. package/lib/server/runDetails.js.map +1 -1
  44. package/lib/server/runEntrypoints.js +1 -1
  45. package/lib/server/runEntrypoints.js.map +1 -1
  46. package/lib/server/runLookup.js +1 -1
  47. package/lib/server/runLookup.js.map +1 -1
  48. package/lib/server/runTour.js +113 -30
  49. package/lib/server/runTour.js.map +1 -1
  50. package/lib/server/runTrace.d.ts +22 -0
  51. package/lib/server/runTrace.js +293 -63
  52. package/lib/server/runTrace.js.map +1 -1
  53. package/lib/structures/ITtscGraphDump.d.ts +13 -13
  54. package/lib/structures/ITtscGraphNode.d.ts +34 -0
  55. package/lib/structures/ITtscGraphTrace.d.ts +1 -1
  56. package/lib/view.js +18 -28
  57. package/lib/view.js.map +1 -1
  58. package/package.json +5 -5
  59. package/src/TtscGraphApplication.ts +5 -1
  60. package/src/bin.ts +13 -3
  61. package/src/index.ts +34 -27
  62. package/src/launcherArgs.ts +168 -0
  63. package/src/model/TtscGraphMemory.ts +34 -60
  64. package/src/model/TtscGraphNodeId.ts +77 -0
  65. package/src/model/TtscGraphSession.ts +228 -55
  66. package/src/model/TtscGraphSourceReader.ts +117 -0
  67. package/src/model/loadGraph.ts +4 -4
  68. package/src/reduce.ts +136 -31
  69. package/src/server/createServer.ts +12 -2
  70. package/src/server/pathPolicy.ts +10 -1
  71. package/src/server/resolveHandle.ts +21 -22
  72. package/src/server/resultAudit.ts +64 -33
  73. package/src/server/resultNext.ts +6 -0
  74. package/src/server/runDetails.ts +57 -113
  75. package/src/server/runEntrypoints.ts +1 -1
  76. package/src/server/runLookup.ts +1 -1
  77. package/src/server/runTour.ts +133 -39
  78. package/src/server/runTrace.ts +401 -65
  79. package/src/structures/ITtscGraphDump.ts +13 -13
  80. package/src/structures/ITtscGraphNode.ts +40 -0
  81. package/src/structures/ITtscGraphTrace.ts +1 -1
  82. package/src/view.ts +25 -23
@@ -3,7 +3,7 @@ import { ITtscGraphEdge } from "../structures/ITtscGraphEdge";
3
3
  import { ITtscGraphEvidence } from "../structures/ITtscGraphEvidence";
4
4
  import { ITtscGraphNode } from "../structures/ITtscGraphNode";
5
5
  import { ITtscGraphTrace } from "../structures/ITtscGraphTrace";
6
- import { isExternalNode, isTestPath } from "./pathPolicy";
6
+ import { isDeclarationFile, isExternalNode, isTestPath } from "./pathPolicy";
7
7
  import { resolveGraphHandle } from "./resolveHandle";
8
8
  import { IRunnerOutput, resultNext } from "./resultNext";
9
9
  import { edgeEvidenceOf, signatureOf } from "./runDetails";
@@ -20,15 +20,23 @@ const MAX_OPEN_DEPTH = 8;
20
20
  const MAX_OPEN_NODES = 32;
21
21
  const MAX_IMPACT_DEPTH = 4;
22
22
  const MAX_IMPACT_NODES = 16;
23
+ // Path mode walks further than an open trace because it is looking for one
24
+ // named end, not building a picture. The cap is the one the request contract
25
+ // publishes for path mode.
26
+ const MAX_PATH_DEPTH = 12;
23
27
  const MAX_HOPS_PER_NODE = 2;
24
28
  const MAX_STEPS = 12;
25
- const EXECUTION_KINDS = new Set<string>([
26
- "calls",
27
- "instantiates",
28
- "accesses",
29
- "renders",
30
- ]);
31
29
  const DISPATCH_KINDS = new Set<string>(["overrides", "implements"]);
30
+ // A declaration whose kind is a type surface never carries a body, and an
31
+ // external leaf carries one the graph deliberately does not hold.
32
+ const BODYLESS_KINDS = new Set<string>([
33
+ "interface",
34
+ "type",
35
+ "external_symbol",
36
+ ]);
37
+ // `abstract` and `declare` are the two keywords that take the body away from a
38
+ // declaration that would otherwise have to have one.
39
+ const BODYLESS_MODIFIERS = new Set<string>(["abstract", "declare"]);
32
40
  // An interface the codebase implements everywhere — a disposable, a listener, a
33
41
  // lifecycle hook — is not a step in one flow, and naming its implementors is a
34
42
  // dump of the codebase rather than an answer. Past this many, the declaration
@@ -143,18 +151,24 @@ export function runTrace(
143
151
  ),
144
152
  };
145
153
  }
146
- const found = findPath(
154
+ const pathDepth = bound(props.maxDepth, MAX_PATH_DEPTH, 1, MAX_PATH_DEPTH);
155
+ const search = findPath(
147
156
  graph,
148
157
  start.node.id,
149
158
  target.node.id,
150
- bound(props.maxDepth, 12, 1, 12),
159
+ pathDepth,
151
160
  focus,
152
161
  includeExternal,
153
162
  );
154
- const path = found?.path ?? [];
155
- const hops = found?.hops ?? [];
163
+ const hasPath = search.found !== undefined;
164
+ const path = search.found?.path ?? [];
165
+ const hops = search.found?.hops ?? [];
166
+ // Junctions explain an absence, so they are only computed once absence is
167
+ // established: a walk the depth bound stopped has not established one, and
168
+ // presenting a shared symbol as "the seam" when a direct call path may run
169
+ // past the bound sends the caller to a seam that is not there.
156
170
  const junctions =
157
- hops.length > 0
171
+ hasPath || search.bounded
158
172
  ? []
159
173
  : junctionsBetween(graph, start.node.id, target.node.id, focus);
160
174
  return {
@@ -167,18 +181,23 @@ export function runTrace(
167
181
  steps: traceSteps(graph, hops),
168
182
  ...(junctions.length > 0 ? { junctions } : {}),
169
183
  },
170
- // An empty path is a fact, not an answer, and the old message called it
184
+ // A missing path is a fact, not an answer, and the old message called it
171
185
  // one: "its path nodes and evidence ranges are what the graph holds
172
- // between the two ends" — of a result that held nothing. The two ends do
173
- // not call each other, which in an event-driven codebase is the common
174
- // case: a pointer handler emits, an emitter's `emit()` runs listeners a
186
+ // between the two ends" — of a result that held nothing. `findPath` says
187
+ // which fact it is; zero hops is instead the valid path from a node to
188
+ // itself. A walk the depth bound stopped establishes nothing at all, so
189
+ // it reports the bound. A walk that ran out of eligible graph did
190
+ // establish an absence, and distinct nodes without a path do not call
191
+ // each other, which in an event-driven codebase is the common case: a
192
+ // pointer handler emits, an emitter's `emit()` runs listeners a
175
193
  // registration put in an array, and no call edge crosses that array. The
176
194
  // callers of the target are the way across, and the graph has them, so
177
195
  // say which call to make instead of handing back an empty result dressed
178
196
  // as the answer. Excalidraw's tour spent eleven calls finding this out.
179
- next:
180
- hops.length > 0
181
- ? pathNext
197
+ next: hasPath
198
+ ? pathNext
199
+ : search.bounded
200
+ ? resultNext("inspect", boundedPathReason(pathDepth), "trace")
182
201
  : junctions.length > 0
183
202
  ? resultNext(
184
203
  "inspect",
@@ -203,24 +222,58 @@ export function runTrace(
203
222
  while (queue.length > 0) {
204
223
  const next: Array<{ id: string; depth: number }> = [];
205
224
  for (const { id, depth } of queue) {
206
- if (depth >= maxDepth) {
207
- truncated = true;
208
- continue;
209
- }
210
- const edges = orderedEdges(
225
+ const { edges: candidates, omitted } = traceEdges(
211
226
  graph,
212
- reverse
213
- ? graph.incoming(id)
214
- : [...graph.outgoing(id), ...dispatchEdges(graph, id, focus)],
215
- direction,
227
+ id,
216
228
  reverse,
229
+ focus,
217
230
  );
231
+ // A hop the hub bound withheld is omitted whatever the depth budget does
232
+ // next, so the flag is set before the boundary check consumes the step.
233
+ if (
234
+ !truncated &&
235
+ omitted.some(
236
+ (edge) =>
237
+ eligibleTraceEndpoint(
238
+ graph,
239
+ edge,
240
+ reverse,
241
+ focus,
242
+ includeExternal,
243
+ ) !== undefined,
244
+ )
245
+ )
246
+ truncated = true;
247
+ if (depth >= maxDepth) {
248
+ // Reaching the configured boundary does not itself omit data. The
249
+ // response is truncated only when the selected walk has another hop
250
+ // (and, for an unseen endpoint, another node) beyond the boundary.
251
+ if (
252
+ candidates.some(
253
+ (edge) =>
254
+ eligibleTraceEndpoint(
255
+ graph,
256
+ edge,
257
+ reverse,
258
+ focus,
259
+ includeExternal,
260
+ ) !== undefined,
261
+ )
262
+ )
263
+ truncated = true;
264
+ continue;
265
+ }
266
+ const edges = orderedEdges(graph, candidates, direction, reverse);
218
267
  for (const edge of edges) {
219
- if (!traversable(edge.kind, focus)) continue;
220
- const otherId = reverse ? edge.from : edge.to;
221
- const other = graph.node(otherId);
222
- if (other === undefined || other.kind === "file") continue;
223
- if (!includeExternal && isExternalNode(other)) continue;
268
+ const endpoint = eligibleTraceEndpoint(
269
+ graph,
270
+ edge,
271
+ reverse,
272
+ focus,
273
+ includeExternal,
274
+ );
275
+ if (endpoint === undefined) continue;
276
+ const { otherId, other } = endpoint;
224
277
  const hop: ITtscGraphTrace.IHop = {
225
278
  from: edge.from,
226
279
  to: edge.to,
@@ -272,6 +325,72 @@ export function runTrace(
272
325
  };
273
326
  }
274
327
 
328
+ interface ITraceEndpoint {
329
+ otherId: string;
330
+ other: ITtscGraphNode;
331
+ }
332
+
333
+ /**
334
+ * Candidate edges in the selected direction before focus and node policy,
335
+ * together with the dispatch hops a bound withheld.
336
+ *
337
+ * The two have to travel together. A hub-suppressed fanout used to arrive as an
338
+ * empty dispatch list, which reads exactly like "this declaration dispatches to
339
+ * nothing", and the caller then reported a complete result while eligible hops
340
+ * had been dropped.
341
+ */
342
+ function traceEdges(
343
+ graph: TtscGraphMemory,
344
+ id: string,
345
+ reverse: boolean,
346
+ focus: ITtscGraphTrace.IRequest["focus"],
347
+ ): { edges: readonly ITtscGraphEdge[]; omitted: readonly ITtscGraphEdge[] } {
348
+ const edges = reverse ? graph.incoming(id) : graph.outgoing(id);
349
+ const dispatched = reverse
350
+ ? reverseDispatchEdges(graph, id, focus)
351
+ : dispatchEdges(graph, id, focus);
352
+ // Nothing to add is the common case, and a walk visits the nodes with the
353
+ // largest edge lists, so hand back the stored list rather than a copy of it.
354
+ return {
355
+ edges:
356
+ dispatched.selected.length === 0
357
+ ? edges
358
+ : [...edges, ...dispatched.selected],
359
+ omitted: dispatched.omitted,
360
+ };
361
+ }
362
+
363
+ /** A dispatch fanout split into what the walk follows and what a bound withheld. */
364
+ interface IDispatchSelection {
365
+ readonly selected: readonly ITtscGraphEdge[];
366
+ readonly omitted: readonly ITtscGraphEdge[];
367
+ }
368
+
369
+ // Returned by every no-dispatch path rather than allocated per call, which is
370
+ // why the lists above are readonly: one shared value that anything could push
371
+ // onto would leak a hop from one node's walk into another's.
372
+ const NO_DISPATCH: IDispatchSelection = { selected: [], omitted: [] };
373
+
374
+ /**
375
+ * The endpoint the selected open trace would represent if no result bound
376
+ * stopped it. The depth probe and traversal share this decision so focus,
377
+ * external nodes, file nodes, and direction cannot disagree about omission.
378
+ */
379
+ function eligibleTraceEndpoint(
380
+ graph: TtscGraphMemory,
381
+ edge: ITtscGraphEdge,
382
+ reverse: boolean,
383
+ focus: ITtscGraphTrace.IRequest["focus"],
384
+ includeExternal: boolean,
385
+ ): ITraceEndpoint | undefined {
386
+ if (!traversable(edge.kind, focus)) return undefined;
387
+ const otherId = reverse ? edge.from : edge.to;
388
+ const other = graph.node(otherId);
389
+ if (other === undefined || other.kind === "file") return undefined;
390
+ if (!includeExternal && isExternalNode(other)) return undefined;
391
+ return { otherId, other };
392
+ }
393
+
275
394
  function traceSteps(
276
395
  graph: TtscGraphMemory,
277
396
  hops: ITtscGraphTrace.IHop[],
@@ -289,11 +408,6 @@ function traceSteps(
289
408
  });
290
409
  }
291
410
 
292
- /**
293
- * The shortest dependency path from `startId` to `targetId` over real (non-
294
- * structural) forward edges, breadth-first, or null when `targetId` is not
295
- * reachable within maxDepth. Returns the nodes in order and the hops between.
296
- */
297
411
  /**
298
412
  * The symbols both ends of an unreachable path touch.
299
413
  *
@@ -382,6 +496,57 @@ function touchedBy(
382
496
  return touched;
383
497
  }
384
498
 
499
+ /**
500
+ * What to say when the depth bound, not the graph, ended a path search.
501
+ *
502
+ * The caller asked a bounded question and the old answer returned a claim about
503
+ * the whole graph — "they touch nothing in common, so the graph holds no
504
+ * connection between them" — which is the worst thing an index can say wrongly:
505
+ * the caller stops asking and either reads files or concludes the dependency is
506
+ * not there. So report the boundary, and make the continuation one the caller
507
+ * can actually take. At the 12-hop ceiling there is no larger `maxDepth` to
508
+ * retry with, and a message that only invites one would be a dead end of its
509
+ * own; two bounded walks from opposite ends cover twice the distance and are
510
+ * requests the tool already answers.
511
+ */
512
+ function boundedPathReason(depth: number): string {
513
+ return (
514
+ `No path was found within the requested depth of ${depth}, but the walk stopped on that bound with eligible graph still ahead of it. ` +
515
+ `This is a boundary, not an absence: the two ends may be connected further out, and nothing here says they are not. ` +
516
+ (depth < MAX_PATH_DEPTH
517
+ ? `Re-run the same path request with a larger \`maxDepth\` (up to ${MAX_PATH_DEPTH}).`
518
+ : `\`maxDepth\` is already at its ${MAX_PATH_DEPTH}-hop maximum, so close the gap from both ends: trace forward from the start, trace the target with \`direction: "reverse"\`, then request the path between a symbol both results name.`)
519
+ );
520
+ }
521
+
522
+ /**
523
+ * What a bounded shortest-path walk learned: the path when it found one, and
524
+ * otherwise whether the walk was stopped by the caller's depth bound or ran the
525
+ * eligible graph out. The two are not the same answer and the caller must not
526
+ * be told the second when only the first happened.
527
+ */
528
+ interface IPathSearch {
529
+ /** The shortest eligible path and its hops, when one was found. */
530
+ found?: { path: ITtscGraphNode[]; hops: ITtscGraphTrace.IHop[] };
531
+
532
+ /**
533
+ * True when the walk stopped at `maxDepth` with an eligible, unvisited node
534
+ * still ahead of it, so nothing was proven about the two ends.
535
+ */
536
+ bounded: boolean;
537
+ }
538
+
539
+ /**
540
+ * The shortest dependency path from `startId` to `targetId` over real (non-
541
+ * structural) forward edges, breadth-first, within `maxDepth` hops.
542
+ *
543
+ * When no path is found, the walk reports whether the bound stopped it. A
544
+ * boundary is a fact about the request; absence is a fact about the graph, and
545
+ * a search that never reached the far side of its own bound has not established
546
+ * one. Eligibility is the open trace's, so a frontier made only of nodes the
547
+ * selected focus, the external-node policy, a file node, or an earlier visit
548
+ * already excluded is not a frontier and the walk is exhausted.
549
+ */
385
550
  function findPath(
386
551
  graph: TtscGraphMemory,
387
552
  startId: string,
@@ -389,10 +554,11 @@ function findPath(
389
554
  maxDepth: number,
390
555
  focus: ITtscGraphTrace.IRequest["focus"],
391
556
  includeExternal: boolean,
392
- ): { path: ITtscGraphNode[]; hops: ITtscGraphTrace.IHop[] } | null {
557
+ ): IPathSearch {
393
558
  const startNode = graph.node(startId);
394
- if (startNode === undefined) return null;
395
- if (startId === targetId) return { path: [startNode], hops: [] };
559
+ if (startNode === undefined) return { bounded: false };
560
+ if (startId === targetId)
561
+ return { found: { path: [startNode], hops: [] }, bounded: false };
396
562
  const parent = new Map<
397
563
  string,
398
564
  {
@@ -402,21 +568,35 @@ function findPath(
402
568
  }
403
569
  >();
404
570
  const visited = new Set<string>([startId]);
571
+ let bounded = false;
405
572
  let queue: Array<{ id: string; depth: number }> = [{ id: startId, depth: 0 }];
406
573
  while (queue.length > 0) {
407
574
  const next: Array<{ id: string; depth: number }> = [];
408
575
  for (const { id, depth } of queue) {
409
- if (depth >= maxDepth) continue;
410
- for (const edge of [
411
- ...graph.outgoing(id),
412
- ...dispatchEdges(graph, id, focus),
413
- ]) {
414
- if (!traversable(edge.kind, focus)) continue;
415
- const otherId = edge.to;
416
- if (visited.has(otherId)) continue;
417
- const other = graph.node(otherId);
418
- if (other === undefined || other.kind === "file") continue;
419
- if (!includeExternal && isExternalNode(other)) continue;
576
+ // The forward step the open trace would take, built in one place so the
577
+ // two walks cannot disagree about what a step follows.
578
+ const { edges: candidates } = traceEdges(graph, id, false, focus);
579
+ if (depth >= maxDepth) {
580
+ if (
581
+ candidates.some(
582
+ (edge) =>
583
+ pathEndpoint(graph, edge, focus, includeExternal, visited) !==
584
+ undefined,
585
+ )
586
+ )
587
+ bounded = true;
588
+ continue;
589
+ }
590
+ for (const edge of candidates) {
591
+ const endpoint = pathEndpoint(
592
+ graph,
593
+ edge,
594
+ focus,
595
+ includeExternal,
596
+ visited,
597
+ );
598
+ if (endpoint === undefined) continue;
599
+ const otherId = endpoint.otherId;
420
600
  visited.add(otherId);
421
601
  const evidence = edgeEvidenceOf(edge);
422
602
  parent.set(otherId, {
@@ -452,14 +632,42 @@ function findPath(
452
632
  hop.evidence = parentEdge.evidence;
453
633
  hops.push(hop);
454
634
  }
455
- return { path, hops };
635
+ return { found: { path, hops }, bounded };
456
636
  }
457
637
  next.push({ id: otherId, depth: depth + 1 });
458
638
  }
459
639
  }
460
640
  queue = next;
461
641
  }
462
- return null;
642
+ return { bounded };
643
+ }
644
+
645
+ /**
646
+ * The node a path expansion would represent, or undefined when the selected
647
+ * policy or an earlier visit excludes it. The expansion and the boundary probe
648
+ * share this decision, so what the walk would have followed and what counts as
649
+ * unexplored graph beyond the bound cannot disagree.
650
+ *
651
+ * A path walk is always forward and, unlike the open trace, a node it already
652
+ * reached is not a continuation: the shortest path to it is already known, so a
653
+ * second arrival adds nothing to explore.
654
+ */
655
+ function pathEndpoint(
656
+ graph: TtscGraphMemory,
657
+ edge: ITtscGraphEdge,
658
+ focus: ITtscGraphTrace.IRequest["focus"],
659
+ includeExternal: boolean,
660
+ visited: ReadonlySet<string>,
661
+ ): ITraceEndpoint | undefined {
662
+ const endpoint = eligibleTraceEndpoint(
663
+ graph,
664
+ edge,
665
+ false,
666
+ focus,
667
+ includeExternal,
668
+ );
669
+ if (endpoint === undefined || visited.has(endpoint.otherId)) return undefined;
670
+ return endpoint;
463
671
  }
464
672
 
465
673
  function orderedEdges(
@@ -544,7 +752,7 @@ function summary(
544
752
  }
545
753
  if (depth !== undefined) out.depth = depth;
546
754
  if (withSignature) {
547
- const sig = signatureOf(graph.project, node);
755
+ const sig = signatureOf(graph, node);
548
756
  if (sig !== undefined) out.signature = sig;
549
757
  }
550
758
  if (withRoles) {
@@ -580,14 +788,36 @@ function dispatchEdges(
580
788
  graph: TtscGraphMemory,
581
789
  id: string,
582
790
  focus: ITtscGraphTrace.IRequest["focus"],
583
- ): ITtscGraphEdge[] {
584
- if (focus === "types" || hasExecutionBody(graph, id)) return [];
585
- const out: ITtscGraphEdge[] = [];
791
+ ): IDispatchSelection {
792
+ if (focus === "types") return NO_DISPATCH;
793
+ // The checker relations first: almost no node has one, and reading a
794
+ // declaration's own facts walks its ownership chain, which is work worth
795
+ // doing only where there is something to dispatch to.
796
+ let relations: ITtscGraphEdge[] | undefined;
586
797
  for (const edge of graph.incoming(id)) {
587
- if (!DISPATCH_KINDS.has(edge.kind)) continue;
798
+ if (DISPATCH_KINDS.has(edge.kind)) (relations ??= []).push(edge);
799
+ }
800
+ if (relations === undefined) return NO_DISPATCH;
801
+ const declaration = graph.node(id);
802
+ if (declaration === undefined || hasDeclarationBody(graph, declaration))
803
+ return NO_DISPATCH;
804
+ const out: ITtscGraphEdge[] = [];
805
+ // Per implementation, not per relation. A class may name one base in two
806
+ // heritage clauses — `class Impl extends Base implements Base` is legal — and
807
+ // the producer records the member pair once per clause, as `overrides` and as
808
+ // `implements`. That is one implementation and one crossing: emitting it
809
+ // twice would repeat the hop and count the same class twice against the hub
810
+ // cut.
811
+ const dispatched = new Set<string>();
812
+ for (const edge of relations) {
813
+ if (dispatched.has(edge.from)) continue;
588
814
  const implementation = graph.node(edge.from);
589
- if (implementation === undefined || !hasExecutionBody(graph, edge.from))
815
+ if (
816
+ implementation === undefined ||
817
+ !hasDeclarationBody(graph, implementation)
818
+ )
590
819
  continue;
820
+ dispatched.add(edge.from);
591
821
  out.push({
592
822
  from: id,
593
823
  to: edge.from,
@@ -595,16 +825,122 @@ function dispatchEdges(
595
825
  ...(edge.evidence !== undefined ? { evidence: edge.evidence } : {}),
596
826
  });
597
827
  }
598
- return out.length >= DISPATCH_HUB ? [] : out;
828
+ // Above the hub cut the fanout stops being a trace and starts being a
829
+ // listing, so the walk does not follow it — but the hops are real and their
830
+ // absence is an omission the caller has to be able to report.
831
+ return out.length >= DISPATCH_HUB
832
+ ? { selected: [], omitted: out }
833
+ : { selected: out, omitted: [] };
834
+ }
835
+
836
+ /**
837
+ * The declaration a change to this implementation is a change to.
838
+ *
839
+ * `dispatches` is one fact and both directions have to see it. Forward, a call
840
+ * that lands on a bodyless declaration continues in the implementation that
841
+ * runs; reverse, a change to that implementation is a change every caller of
842
+ * the declaration feels — and `impact` is the query an agent runs _before_
843
+ * editing a method. The checker relation is oriented implementation-to-base, so
844
+ * from the implementation it is an outgoing edge and a reverse walk, which only
845
+ * reads incoming edges, never sees it. Both halves of the path are one step
846
+ * away in a direction the traversal does not take.
847
+ *
848
+ * The synthetic edge is the forward one, unchanged, so eligibility, checker
849
+ * validity, and the hub cut cannot drift apart between the two directions: ask
850
+ * the base what it dispatches to, and keep the edges that land here.
851
+ */
852
+ function reverseDispatchEdges(
853
+ graph: TtscGraphMemory,
854
+ id: string,
855
+ focus: ITtscGraphTrace.IRequest["focus"],
856
+ ): IDispatchSelection {
857
+ if (focus === "types") return NO_DISPATCH;
858
+ const selected: ITtscGraphEdge[] = [];
859
+ const omitted: ITtscGraphEdge[] = [];
860
+ const bases = new Set<string>();
861
+ for (const edge of graph.outgoing(id)) {
862
+ if (!DISPATCH_KINDS.has(edge.kind) || bases.has(edge.to)) continue;
863
+ bases.add(edge.to);
864
+ const fanout = dispatchEdges(graph, edge.to, focus);
865
+ for (const dispatch of fanout.selected)
866
+ if (dispatch.to === id) selected.push(dispatch);
867
+ // The base's fanout is bounded as a whole, so a reverse walk that lands on
868
+ // a suppressed sibling has the same omission to report as the forward one.
869
+ for (const dispatch of fanout.omitted)
870
+ if (dispatch.to === id) omitted.push(dispatch);
871
+ }
872
+ return { selected, omitted };
873
+ }
874
+
875
+ /**
876
+ * Whether the declaration this node stands for has a body of its own.
877
+ *
878
+ * Having a body and naming a modeled dependency are different facts. Counting
879
+ * outgoing `calls`/`accesses`/`instantiates`/`renders` edges measures the
880
+ * second and answers as though it were the first, and it is wrong in both
881
+ * directions: an implementation whose body returns a literal, throws, or only
882
+ * moves locals around has degree zero and was refused as a dispatch target,
883
+ * while a concrete base method was promoted through its own override the moment
884
+ * its body stopped naming anything the graph models. Two graphs identical in
885
+ * every declaration fact then answered differently because of one statement
886
+ * inside a body.
887
+ *
888
+ * So read the declaration instead. A type surface has no body; `abstract` and
889
+ * `declare` take it away; an interface member and anything inside an ambient
890
+ * container never had one; a `.d.ts` declaration is ambient whether or not the
891
+ * keyword is written; and an external leaf has a body the graph deliberately
892
+ * does not hold. Everything else is a concrete declaration, which is a real
893
+ * destination and is never promoted through an override, whatever it calls.
894
+ */
895
+ export function hasDeclarationBody(
896
+ graph: TtscGraphMemory,
897
+ node: ITtscGraphNode,
898
+ ): boolean {
899
+ if (BODYLESS_KINDS.has(node.kind)) return false;
900
+ if (isExternalNode(node)) return false;
901
+ if (isDeclarationFile(node.file)) return false;
902
+ if (hasBodylessModifier(node)) return false;
903
+ return !inBodylessContainer(graph, node);
599
904
  }
600
905
 
601
- /** Whether the declaration has a body: something it calls, reads, or renders. */
602
- function hasExecutionBody(graph: TtscGraphMemory, id: string): boolean {
603
- for (const edge of graph.outgoing(id))
604
- if (EXECUTION_KINDS.has(edge.kind)) return true;
906
+ function hasBodylessModifier(node: ITtscGraphNode): boolean {
907
+ return node.modifiers?.some((m) => BODYLESS_MODIFIERS.has(m)) === true;
908
+ }
909
+
910
+ /**
911
+ * Whether an owner up the `contains` tree makes this declaration bodyless: an
912
+ * interface, or an ambient container. A member writes no keyword of its own —
913
+ * `declare` on a class or namespace is not repeated on what it holds — so the
914
+ * fact lives on the owner and the walk has to go get it.
915
+ */
916
+ function inBodylessContainer(
917
+ graph: TtscGraphMemory,
918
+ node: ITtscGraphNode,
919
+ ): boolean {
920
+ const seen = new Set<string>([node.id]);
921
+ let current: ITtscGraphNode | undefined = node;
922
+ while (current !== undefined) {
923
+ const container: ITtscGraphNode | undefined = containerOf(graph, current);
924
+ if (container === undefined || container.kind === "file") return false;
925
+ if (seen.has(container.id)) return false;
926
+ seen.add(container.id);
927
+ if (container.kind === "interface" || hasBodylessModifier(container))
928
+ return true;
929
+ current = container;
930
+ }
605
931
  return false;
606
932
  }
607
933
 
934
+ /** The declaration that owns this one, through the synthesized ownership tree. */
935
+ function containerOf(
936
+ graph: TtscGraphMemory,
937
+ node: ITtscGraphNode,
938
+ ): ITtscGraphNode | undefined {
939
+ for (const edge of graph.incoming(node.id))
940
+ if (edge.kind === "contains") return graph.node(edge.from);
941
+ return undefined;
942
+ }
943
+
608
944
  /** An edge the trace should follow: a real dependency, not a structural edge. */
609
945
  function traversable(
610
946
  kind: string,
@@ -13,19 +13,19 @@ import { ITtscGraphSpan } from "./ITtscGraphSpan";
13
13
  * warm model while project inputs stay unchanged; the bundled 3D viewer reduces
14
14
  * the same dump.
15
15
  *
16
- * `project` is absolute. Every other path is relative to it — `tsconfig`, and
17
- * the `file` fields on nodes, edges, diagnostics, and the provenance manifest.
18
- *
19
- * Two kinds of path fall outside the project and so cannot be relative to it: a
20
- * dependency keeps its `node_modules/`-relative tail, which is what makes a
21
- * dependency leaf readable, and anything else the compiler loaded keeps the
22
- * identity the compiler gave it a virtual lib stays `bundled:///…`.
16
+ * `project` is the producer-local absolute locator. Every identity-bearing path
17
+ * uses one schema-v6 coordinate relative to it: project files are ordinary
18
+ * relative paths; same-filesystem siblings use `../` segments; package files
19
+ * keep their full resolution context (including version/peer-store segments);
20
+ * and a virtual compiler source stays `bundled:///…`. Raw absolute identities
21
+ * are never emitted. A source on another drive or UNC share makes the producer
22
+ * fail unless a future contract supplies a logical root for it.
23
23
  */
24
24
  export interface ITtscGraphDump {
25
25
  /** Absolute path of the project root the graph was built for. */
26
26
  project: string;
27
27
 
28
- /** The tsconfig the program was loaded from, relative to `project`. */
28
+ /** The tsconfig the program was loaded from, in the dump's path vocabulary. */
29
29
  tsconfig: string;
30
30
 
31
31
  /** Evidence about the one program that produced everything below. */
@@ -143,16 +143,16 @@ export namespace ITtscGraphDump {
143
143
 
144
144
  /** A root file attributed to the config that named it. */
145
145
  export interface IRootFile {
146
- /** The tsconfig that named this root, project-relative. */
146
+ /** The tsconfig that named this root, in the dump's path vocabulary. */
147
147
  config: string;
148
148
 
149
- /** The root file, project-relative. */
149
+ /** The root file, in the dump's path vocabulary. */
150
150
  file: string;
151
151
  }
152
152
 
153
153
  /** A file and the hex-encoded SHA-256 of its on-disk bytes. */
154
154
  export interface IFileDigest {
155
- /** Project-relative. */
155
+ /** In the dump's path vocabulary. */
156
156
  file: string;
157
157
 
158
158
  /** Hex-encoded SHA-256. */
@@ -169,7 +169,7 @@ export namespace ITtscGraphDump {
169
169
  * on every build.
170
170
  */
171
171
  export interface ISourceDigest {
172
- /** Project-relative. */
172
+ /** In the dump's path vocabulary. */
173
173
  file: string;
174
174
 
175
175
  /**
@@ -199,7 +199,7 @@ export namespace ITtscGraphDump {
199
199
 
200
200
  /** One compiler diagnostic from the generation that produced the facts. */
201
201
  export interface IDiagnostic {
202
- /** Project-relative. */
202
+ /** In the dump's path vocabulary. */
203
203
  file: string;
204
204
 
205
205
  /** 1-based line. */