@ttsc/graph 0.19.2 → 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 (92) hide show
  1. package/lib/TtscGraphApplication.js +5 -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 +25 -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 +423 -171
  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 +14 -0
  24. package/lib/model/loadGraph.js +256 -143
  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/resolveGraphBinary.d.ts +9 -3
  30. package/lib/resolveGraphBinary.js +9 -3
  31. package/lib/resolveGraphBinary.js.map +1 -1
  32. package/lib/server/createServer.js +22 -11
  33. package/lib/server/createServer.js.map +1 -1
  34. package/lib/server/pathPolicy.d.ts +6 -0
  35. package/lib/server/pathPolicy.js +10 -1
  36. package/lib/server/pathPolicy.js.map +1 -1
  37. package/lib/server/resolveHandle.js +22 -18
  38. package/lib/server/resolveHandle.js.map +1 -1
  39. package/lib/server/resultAudit.d.ts +56 -14
  40. package/lib/server/resultAudit.js +90 -24
  41. package/lib/server/resultAudit.js.map +1 -1
  42. package/lib/server/resultNext.d.ts +5 -0
  43. package/lib/server/resultNext.js.map +1 -1
  44. package/lib/server/runDetails.d.ts +9 -2
  45. package/lib/server/runDetails.js +132 -157
  46. package/lib/server/runDetails.js.map +1 -1
  47. package/lib/server/runEntrypoints.js +1 -1
  48. package/lib/server/runEntrypoints.js.map +1 -1
  49. package/lib/server/runLookup.js +1 -1
  50. package/lib/server/runLookup.js.map +1 -1
  51. package/lib/server/runTour.js +113 -30
  52. package/lib/server/runTour.js.map +1 -1
  53. package/lib/server/runTrace.d.ts +22 -0
  54. package/lib/server/runTrace.js +293 -63
  55. package/lib/server/runTrace.js.map +1 -1
  56. package/lib/structures/ITtscGraphApplication.d.ts +8 -7
  57. package/lib/structures/ITtscGraphDetails.d.ts +18 -8
  58. package/lib/structures/ITtscGraphDump.d.ts +13 -13
  59. package/lib/structures/ITtscGraphNode.d.ts +71 -0
  60. package/lib/structures/ITtscGraphTour.d.ts +1 -1
  61. package/lib/structures/ITtscGraphTrace.d.ts +1 -1
  62. package/lib/view.js +18 -28
  63. package/lib/view.js.map +1 -1
  64. package/package.json +7 -4
  65. package/src/TtscGraphApplication.ts +8 -1
  66. package/src/bin.ts +13 -3
  67. package/src/index.ts +35 -27
  68. package/src/launcherArgs.ts +168 -0
  69. package/src/model/TtscGraphMemory.ts +34 -60
  70. package/src/model/TtscGraphNodeId.ts +77 -0
  71. package/src/model/TtscGraphSession.ts +253 -53
  72. package/src/model/TtscGraphSourceReader.ts +117 -0
  73. package/src/model/loadGraph.ts +6 -1
  74. package/src/reduce.ts +136 -31
  75. package/src/resolveGraphBinary.ts +9 -3
  76. package/src/server/createServer.ts +12 -2
  77. package/src/server/pathPolicy.ts +10 -1
  78. package/src/server/resolveHandle.ts +21 -22
  79. package/src/server/resultAudit.ts +93 -23
  80. package/src/server/resultNext.ts +6 -0
  81. package/src/server/runDetails.ts +134 -165
  82. package/src/server/runEntrypoints.ts +1 -1
  83. package/src/server/runLookup.ts +1 -1
  84. package/src/server/runTour.ts +133 -39
  85. package/src/server/runTrace.ts +401 -65
  86. package/src/structures/ITtscGraphApplication.ts +8 -7
  87. package/src/structures/ITtscGraphDetails.ts +18 -8
  88. package/src/structures/ITtscGraphDump.ts +13 -13
  89. package/src/structures/ITtscGraphNode.ts +80 -0
  90. package/src/structures/ITtscGraphTour.ts +1 -1
  91. package/src/structures/ITtscGraphTrace.ts +1 -1
  92. 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,
@@ -169,13 +169,14 @@ export namespace ITtscGraphApplication {
169
169
  * resolves to the type-checked program for the snapshot the call synced to,
170
170
  * so opening a file it cites only returns a fact already in it.
171
171
  *
172
- * The audit is operation-aware. For the exact operations (`trace`,
173
- * `details`, `overview`) it reports the result as the structure the graph
174
- * holds for the handles named, bounded only where `truncated` says. For the
175
- * ranked operations (`lookup`, `entrypoints`, `tour`) it adds that the
176
- * selection is heuristic matched, scored, ranked, and limited against the
177
- * question so the facts are verified but the shortlist's coverage is the
178
- * caller's to judge.
172
+ * The audit is operation-aware. For the walks from a named handle (`trace`,
173
+ * `overview`) it reports the result as the structure the graph holds,
174
+ * bounded where `truncated` says. For `details` it reports the two halves of
175
+ * a resolved symbol: its own shape returned whole, its fan-out returned as a
176
+ * slice with `trace` for the rest. For the ranked operations (`lookup`,
177
+ * `entrypoints`, `tour`) it adds that the selection is heuristic matched,
178
+ * scored, ranked, and limited against the question — so the facts are
179
+ * verified but the shortlist's coverage is the caller's to judge.
179
180
  */
180
181
  audit: string;
181
182
 
@@ -49,24 +49,26 @@ export namespace ITtscGraphDetails {
49
49
  neighbors?: boolean;
50
50
 
51
51
  /**
52
- * Maximum dependencies and dependents per side when `neighbors:true`. Above
53
- * a few is usually overfetch; call `trace` for flow instead.
52
+ * Dependencies and dependents per side when `neighbors:true`. A small
53
+ * orientation slice by default; what uses a symbol grows with its
54
+ * popularity, so `trace` answers the whole "who uses this".
54
55
  *
55
56
  * @default 2
56
57
  */
57
58
  neighborLimit?: number;
58
59
 
59
60
  /**
60
- * Maximum owned members for a container or object literal.
61
- *
62
- * @default 6
61
+ * Owned members for a container or object literal. The complete outline by
62
+ * default — a class's members and an enum's are the symbol itself, so they
63
+ * are not sampled. Pass a number to cap.
63
64
  */
64
65
  memberLimit?: number;
65
66
 
66
67
  /**
67
- * Maximum direct execution and type references per group.
68
+ * Direct execution and type references per group. A small orientation slice
69
+ * by default; `trace` follows the whole fan-out.
68
70
  *
69
- * @default 1
71
+ * @default 2
70
72
  */
71
73
  dependencyLimit?: number;
72
74
 
@@ -145,7 +147,15 @@ export namespace ITtscGraphDetails {
145
147
  /** Concrete nodes that implement or override this interface/base member. */
146
148
  implementedBy?: IReference[];
147
149
 
148
- /** String literal values from the signature. */
150
+ /**
151
+ * The complete value set a type alias or enum admits, in TypeScript source
152
+ * form (`"a"`, `1`, `true`, `null`) — the checker's resolved union members,
153
+ * not the quoted tokens that happened to fit in `signature`.
154
+ *
155
+ * Absent when the type has no enumerable value set. A `signature` is capped
156
+ * at the declaration head, so for a union or enum written across several
157
+ * lines this is the field that carries the members.
158
+ */
149
159
  literals?: string[];
150
160
 
151
161
  /**