@ttsc/graph 0.18.3 → 0.19.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 (125) hide show
  1. package/README.md +231 -36
  2. package/lib/TtscGraphApplication.d.ts +4 -3
  3. package/lib/TtscGraphApplication.js +54 -19
  4. package/lib/TtscGraphApplication.js.map +1 -1
  5. package/lib/index.d.ts +9 -4
  6. package/lib/index.js +24 -5
  7. package/lib/index.js.map +1 -1
  8. package/lib/model/TtscGraphMemory.js +80 -22
  9. package/lib/model/TtscGraphMemory.js.map +1 -1
  10. package/lib/model/TtscGraphSession.d.ts +36 -0
  11. package/lib/model/TtscGraphSession.js +457 -0
  12. package/lib/model/TtscGraphSession.js.map +1 -0
  13. package/lib/model/loadGraph.d.ts +3 -3
  14. package/lib/model/loadGraph.js +94 -141
  15. package/lib/model/loadGraph.js.map +1 -1
  16. package/lib/resolveGraphBinary.d.ts +4 -2
  17. package/lib/resolveGraphBinary.js +7 -3
  18. package/lib/resolveGraphBinary.js.map +1 -1
  19. package/lib/server/createServer.d.ts +16 -9
  20. package/lib/server/createServer.js +286 -190
  21. package/lib/server/createServer.js.map +1 -1
  22. package/lib/server/exportSurface.d.ts +26 -0
  23. package/lib/server/exportSurface.js +44 -0
  24. package/lib/server/exportSurface.js.map +1 -0
  25. package/lib/server/resolveHandle.d.ts +26 -1
  26. package/lib/server/resolveHandle.js +124 -4
  27. package/lib/server/resolveHandle.js.map +1 -1
  28. package/lib/server/resultAudit.d.ts +74 -0
  29. package/lib/server/resultAudit.js +110 -0
  30. package/lib/server/resultAudit.js.map +1 -0
  31. package/lib/server/resultNext.d.ts +9 -0
  32. package/lib/server/resultNext.js +11 -0
  33. package/lib/server/resultNext.js.map +1 -0
  34. package/lib/server/runDetails.d.ts +15 -3
  35. package/lib/server/runDetails.js +105 -23
  36. package/lib/server/runDetails.js.map +1 -1
  37. package/lib/server/runEntrypoints.d.ts +2 -1
  38. package/lib/server/runEntrypoints.js +17 -10
  39. package/lib/server/runEntrypoints.js.map +1 -1
  40. package/lib/server/runLookup.d.ts +2 -1
  41. package/lib/server/runLookup.js +21 -9
  42. package/lib/server/runLookup.js.map +1 -1
  43. package/lib/server/runOverview.d.ts +2 -1
  44. package/lib/server/runOverview.js +5 -4
  45. package/lib/server/runOverview.js.map +1 -1
  46. package/lib/server/runTour.d.ts +2 -1
  47. package/lib/server/runTour.js +707 -331
  48. package/lib/server/runTour.js.map +1 -1
  49. package/lib/server/runTrace.d.ts +2 -1
  50. package/lib/server/runTrace.js +234 -54
  51. package/lib/server/runTrace.js.map +1 -1
  52. package/lib/server/startServer.d.ts +3 -2
  53. package/lib/server/startServer.js +13 -7
  54. package/lib/server/startServer.js.map +1 -1
  55. package/lib/structures/ITtscGraphApplication.d.ts +122 -114
  56. package/lib/structures/ITtscGraphDecorator.d.ts +4 -7
  57. package/lib/structures/ITtscGraphDetails.d.ts +51 -43
  58. package/lib/structures/ITtscGraphDump.d.ts +36 -11
  59. package/lib/structures/ITtscGraphEdge.d.ts +4 -6
  60. package/lib/structures/ITtscGraphEntrypoints.d.ts +10 -27
  61. package/lib/structures/ITtscGraphEscape.d.ts +6 -16
  62. package/lib/structures/ITtscGraphEvidence.d.ts +5 -8
  63. package/lib/structures/ITtscGraphLookup.d.ts +9 -20
  64. package/lib/structures/ITtscGraphNext.d.ts +10 -7
  65. package/lib/structures/ITtscGraphNode.d.ts +17 -8
  66. package/lib/structures/ITtscGraphOverview.d.ts +10 -14
  67. package/lib/structures/ITtscGraphSpan.d.ts +29 -0
  68. package/lib/structures/{ITtscGraphDiagnostic.js → ITtscGraphSpan.js} +1 -1
  69. package/lib/structures/ITtscGraphSpan.js.map +1 -0
  70. package/lib/structures/ITtscGraphTour.d.ts +65 -23
  71. package/lib/structures/ITtscGraphTrace.d.ts +72 -49
  72. package/lib/structures/TtscGraphEdgeKind.d.ts +7 -1
  73. package/lib/structures/index.d.ts +1 -1
  74. package/lib/structures/index.js +1 -1
  75. package/lib/structures/index.js.map +1 -1
  76. package/lib/view.js +3 -1
  77. package/lib/view.js.map +1 -1
  78. package/package.json +4 -4
  79. package/src/TtscGraphApplication.ts +66 -31
  80. package/src/index.ts +23 -5
  81. package/src/model/TtscGraphMemory.ts +86 -22
  82. package/src/model/TtscGraphSession.ts +204 -0
  83. package/src/model/loadGraph.ts +6 -4
  84. package/src/resolveGraphBinary.ts +7 -3
  85. package/src/server/createServer.ts +24 -17
  86. package/src/server/exportSurface.ts +40 -0
  87. package/src/server/resolveHandle.ts +141 -4
  88. package/src/server/resultAudit.ts +109 -0
  89. package/src/server/resultNext.ts +22 -0
  90. package/src/server/runDetails.ts +112 -28
  91. package/src/server/runEntrypoints.ts +26 -17
  92. package/src/server/runLookup.ts +29 -18
  93. package/src/server/runOverview.ts +9 -10
  94. package/src/server/runTour.ts +764 -363
  95. package/src/server/runTrace.ts +265 -71
  96. package/src/server/startServer.ts +13 -8
  97. package/src/structures/ITtscGraphApplication.ts +124 -114
  98. package/src/structures/ITtscGraphDecorator.ts +4 -7
  99. package/src/structures/ITtscGraphDetails.ts +60 -46
  100. package/src/structures/ITtscGraphDump.ts +41 -11
  101. package/src/structures/ITtscGraphEdge.ts +4 -6
  102. package/src/structures/ITtscGraphEntrypoints.ts +10 -30
  103. package/src/structures/ITtscGraphEscape.ts +6 -19
  104. package/src/structures/ITtscGraphEvidence.ts +5 -8
  105. package/src/structures/ITtscGraphLookup.ts +9 -22
  106. package/src/structures/ITtscGraphNext.ts +10 -7
  107. package/src/structures/ITtscGraphNode.ts +18 -8
  108. package/src/structures/ITtscGraphOverview.ts +10 -17
  109. package/src/structures/ITtscGraphSpan.ts +33 -0
  110. package/src/structures/ITtscGraphTour.ts +70 -28
  111. package/src/structures/ITtscGraphTrace.ts +82 -51
  112. package/src/structures/TtscGraphEdgeKind.ts +7 -0
  113. package/src/structures/index.ts +1 -1
  114. package/src/view.ts +3 -1
  115. package/lib/server/accessAliases.d.ts +0 -8
  116. package/lib/server/accessAliases.js +0 -53
  117. package/lib/server/accessAliases.js.map +0 -1
  118. package/lib/server/resultGuide.d.ts +0 -3
  119. package/lib/server/resultGuide.js +0 -16
  120. package/lib/server/resultGuide.js.map +0 -1
  121. package/lib/structures/ITtscGraphDiagnostic.d.ts +0 -27
  122. package/lib/structures/ITtscGraphDiagnostic.js.map +0 -1
  123. package/src/server/accessAliases.ts +0 -55
  124. package/src/server/resultGuide.ts +0 -20
  125. package/src/structures/ITtscGraphDiagnostic.ts +0 -34
@@ -1,27 +1,54 @@
1
1
  import { TtscGraphMemory } from "../model/TtscGraphMemory";
2
2
  import { ITtscGraphDetails } from "../structures/ITtscGraphDetails";
3
+ import { ITtscGraphEdge } from "../structures/ITtscGraphEdge";
3
4
  import { ITtscGraphEntrypoints } from "../structures/ITtscGraphEntrypoints";
4
5
  import { ITtscGraphEvidence } from "../structures/ITtscGraphEvidence";
6
+ import { ITtscGraphNext } from "../structures/ITtscGraphNext";
5
7
  import { ITtscGraphNode } from "../structures/ITtscGraphNode";
6
8
  import { ITtscGraphTour } from "../structures/ITtscGraphTour";
7
9
  import { ITtscGraphTrace } from "../structures/ITtscGraphTrace";
10
+ import { exportFanIn, hasExportSurface } from "./exportSurface";
8
11
  import { isSupportPath, isTestPath } from "./pathPolicy";
9
- import { resultGuide, resultNext } from "./resultGuide";
10
- import { decoratorsOf, runDetails, signatureOf } from "./runDetails";
12
+ import { resolveGraphHandle } from "./resolveHandle";
13
+ import { IRunnerOutput, resultNext } from "./resultNext";
14
+ import { decoratorsOf, docOf, runDetails, signatureOf } from "./runDetails";
11
15
  import { runEntrypoints } from "./runEntrypoints";
12
16
  import { runTrace } from "./runTrace";
13
17
 
14
18
  const DEFAULT_LIMIT = 5;
15
19
  const MAX_LIMIT = 5;
16
- const FLOW_SEEDS = 5;
17
- const DETAIL_SEEDS = 3;
20
+ /**
21
+ * The share of a tour's seeds the symbols the caller named may take.
22
+ *
23
+ * Two authorities pick a tour, and neither is allowed to be the only one. Half
24
+ * the seeds go to what the caller says the answer is made of — it has read the
25
+ * question and the codebase's docs, and the tour has read neither. The other
26
+ * half goes to what the graph says is central to the question, and the names do
27
+ * not touch that ranking, because a caller cannot name what it does not know is
28
+ * there: Opus named ten symbols along RxJS's subscribe path, and `operate` —
29
+ * the head of the operator chain the question asked about, and the second seed
30
+ * of the same tour with no names at all — fell out of the tour it did not
31
+ * name.
32
+ */
33
+ const NAMED_SHARE = 0.5;
34
+ const FLOW_SEEDS = 4;
35
+ /** How many ranked seeds deep to look for flows that actually move. */
36
+ const FLOW_SEED_CANDIDATES = 4;
18
37
  const TEST_SEEDS = 3;
19
38
  const MAX_FLOW_ANCHORS = 8;
20
39
  const MAX_NEARBY = 10;
21
40
  const MAX_TESTS = 8;
22
41
  const MAX_READ_NEXT = 14;
23
- const TOUR_TRACE_MAX_DEPTH = 3;
24
- const TOUR_TRACE_MAX_NODES = 16;
42
+ // A public entry stands several hops above the code that does the work: an app
43
+ // factory calls a mount, which calls a renderer, which calls the patch. Three
44
+ // hops stopped at that boundary, and the model finished the chain by hand —
45
+ // "the tour stopped short of the actual patch engine", then four more calls.
46
+ // The flow reaches the work now.
47
+ // Two flows that land in the same places are one flow. Above this share of a
48
+ // candidate's reached set already told, it is a synonym of a flow the tour has.
49
+ const FLOW_OVERLAP = 0.6;
50
+ const TOUR_TRACE_MAX_DEPTH = 6;
51
+ const TOUR_TRACE_MAX_NODES = 18;
25
52
  const STRUCTURAL_KINDS = new Set<string>(["contains", "exports", "imports"]);
26
53
  const EXECUTION_KINDS = new Set<string>([
27
54
  "calls",
@@ -39,69 +66,6 @@ const TOUR_SEED_KINDS = new Set<string>([
39
66
  "namespace",
40
67
  "enum",
41
68
  ]);
42
- const QUERY_STOP_WORDS = new Set<string>([
43
- "about",
44
- "after",
45
- "and",
46
- "are",
47
- "api",
48
- "architecture",
49
- "around",
50
- "before",
51
- "based",
52
- "behavior",
53
- "between",
54
- "but",
55
- "can",
56
- "central",
57
- "change",
58
- "changes",
59
- "code",
60
- "does",
61
- "for",
62
- "first",
63
- "find",
64
- "flow",
65
- "from",
66
- "has",
67
- "have",
68
- "how",
69
- "include",
70
- "including",
71
- "implementation",
72
- "into",
73
- "its",
74
- "nearby",
75
- "need",
76
- "needs",
77
- "new",
78
- "next",
79
- "path",
80
- "paths",
81
- "project",
82
- "public",
83
- "read",
84
- "real",
85
- "runtime",
86
- "should",
87
- "show",
88
- "that",
89
- "the",
90
- "this",
91
- "test",
92
- "tests",
93
- "trace",
94
- "through",
95
- "with",
96
- "without",
97
- "tour",
98
- "typescript",
99
- "user",
100
- "what",
101
- "where",
102
- "which",
103
- "work",
104
- ]);
105
69
 
106
70
  /**
107
71
  * Compose a repository-orientation/code-tour answer surface from existing graph
@@ -111,22 +75,53 @@ const QUERY_STOP_WORDS = new Set<string>([
111
75
  export function runTour(
112
76
  graph: TtscGraphMemory,
113
77
  props: ITtscGraphTour.IRequest,
114
- ): ITtscGraphTour {
115
- const query = props.query.trim();
78
+ question: string,
79
+ ): IRunnerOutput<ITtscGraphTour> {
80
+ const query = question.trim();
81
+ const named = namedNodesOf(graph, props.reinterpretations);
82
+ // The words that rank the tour are the caller's names, never the question's
83
+ // prose. Which words in a question are its keywords is the caller's judgement
84
+ // to make -- it read the question -- and the server used to make it instead,
85
+ // with a list of sixty-eight words it happened to think were filler.
86
+ const terms = queryTermsOf(graph, props.reinterpretations);
116
87
  const limit = bound(props.limit, DEFAULT_LIMIT, 1, MAX_LIMIT);
117
88
  const entry = runEntrypoints(graph, {
118
89
  type: "entrypoints",
119
90
  query,
120
91
  limit,
121
92
  neighbors: 1,
122
- });
123
- const seeds = tourSeedsOf(graph, entry, query, limit);
93
+ }).result;
94
+ const seeds = tourSeedsOf(graph, entry, query, limit, named, terms);
124
95
  const seedIds = seeds.map((node) => node.id);
125
- const flowSeedIds = flowSeedIdsOf(seeds);
126
96
  const entrypoints = seeds.map((node) => graphNodeOf(graph, node));
127
97
 
98
+ // A flow that goes nowhere is a wasted slot: a seed can match the question by
99
+ // name and still drive nothing (a decorator factory, a metadata helper), and
100
+ // tracing the top five seeds blind spent the tour's flows on them — the model
101
+ // read "the tour didn't surface the request pipeline" and went to the files.
102
+ // Walk the ranked seeds instead, keeping the ones whose trace actually moves,
103
+ // until the tour has its flows.
104
+ // A flow the tour already told is not a second flow. zod's four public parse
105
+ // entries — parse, parseAsync, safeParse, safeParseAsync — run the same chain
106
+ // into the same internals, and the tour spent all four of its slots saying it
107
+ // four times: 18 KB of payload, three quarters of it a repeat, and the rest of
108
+ // the library (schema construction, checks, error formatting) unmentioned. A
109
+ // candidate whose trace lands where a kept flow already landed is a synonym,
110
+ // so keep the first and walk on to one that tells something else.
128
111
  const primaryFlow: ITtscGraphTour.IFlow[] = [];
129
- for (const id of flowSeedIds.slice(0, FLOW_SEEDS)) {
112
+ const told: Set<string>[] = [];
113
+
114
+ for (const id of flowSeedIdsOf(
115
+ tourSeedsOf(
116
+ graph,
117
+ entry,
118
+ query,
119
+ limit * FLOW_SEED_CANDIDATES,
120
+ named,
121
+ terms,
122
+ ),
123
+ )) {
124
+ if (primaryFlow.length >= FLOW_SEEDS) break;
130
125
  const trace = runTrace(graph, {
131
126
  type: "trace",
132
127
  from: id,
@@ -134,18 +129,33 @@ export function runTour(
134
129
  focus: "execution",
135
130
  maxDepth: TOUR_TRACE_MAX_DEPTH,
136
131
  maxNodes: TOUR_TRACE_MAX_NODES,
137
- });
132
+ }).result;
138
133
  const start = trace.start;
139
134
  if (start === undefined) continue;
140
135
  const hops = trace.hops.filter((hop) => isTourHop(graph, hop));
141
- const reached = trace.reached.filter(isTourTraceNode);
136
+ if (hops.length === 0) continue;
137
+ const reached = trace.reached.filter((node) =>
138
+ isTourTraceNode(graph, node),
139
+ );
140
+ const landed = new Set(reached.map((node) => node.id));
141
+ if (told.some((earlier) => overlaps(landed, earlier))) continue;
142
+ told.push(landed);
143
+ const steps = hops
144
+ .slice(0, MAX_FLOW_ANCHORS)
145
+ .map((hop) => flowStepOf(graph, hop));
146
+ // Every node the flow reached is listed, including the ones its steps name.
147
+ // A step is prose — `App.render -[calls at App.tsx:2093]-> renderScene` — and
148
+ // it carries the name and the citation but not the *handle*, and the handle
149
+ // is what a second call needs. Holding back the nodes the steps had named
150
+ // took their ids away with them: Sonnet traced `mutateElement` by name, got
151
+ // the several nodes that name, and re-traced it by id — two calls for one
152
+ // symbol, four times over in a single Excalidraw tour, which went from five
153
+ // graph calls to fifteen. What `reached` is for is not the story, which the
154
+ // steps tell; it is the handles to go on with.
142
155
  primaryFlow.push({
143
- start: traceNodeOf(start),
144
- steps: hops
145
- .slice(0, MAX_FLOW_ANCHORS)
146
- .map((hop) => flowStepOf(graph, hop)),
156
+ start: flowStartOf(start),
157
+ steps,
147
158
  reached: reached.map(traceNodeOf),
148
- anchors: flowAnchorsOf(trace, hops, reached).slice(0, MAX_FLOW_ANCHORS),
149
159
  ...(trace.truncated ? { truncated: true } : {}),
150
160
  });
151
161
  }
@@ -155,12 +165,12 @@ export function runTour(
155
165
  ? undefined
156
166
  : runDetails(graph, {
157
167
  type: "details",
158
- handles: seedIds.slice(0, DETAIL_SEEDS),
168
+ handles: seedIds,
159
169
  neighbors: true,
160
170
  memberLimit: 4,
161
171
  dependencyLimit: 2,
162
172
  neighborLimit: 2,
163
- });
173
+ }).result;
164
174
  const nearby = details === undefined ? [] : nearbyAnchorsOf(details);
165
175
 
166
176
  const tests =
@@ -179,40 +189,116 @@ export function runTour(
179
189
  ...entrypoints.flatMap((node) =>
180
190
  anchorFromNode("central entrypoint", node),
181
191
  ),
182
- ...primaryFlow.flatMap((flow) => flow.anchors),
183
192
  ...nearby,
184
193
  ...tests,
185
194
  ]).slice(0, MAX_READ_NEXT);
186
195
 
187
196
  return {
188
- type: "tour",
189
- query,
190
- entrypoints,
191
- primaryFlow,
192
- nearby: nearby.slice(0, MAX_NEARBY),
193
- tests: tests.slice(0, MAX_TESTS),
194
- answerAnchors,
195
- next: resultNext(
196
- "answer",
197
- "This tour is the complete index-level answer surface: central entrypoints, primary flow, nearby paths, tests, and answer anchors.",
198
- ),
199
- guide: resultGuide(
200
- "Use this tour as the answer-ready index. Do not split it into extra lookup/details/trace calls unless the user asks for a named missing symbol or exact source text.",
201
- ),
202
- ...(entry.truncated ||
203
- primaryFlow.some((flow) => flow.truncated === true) ||
204
- nearby.length > MAX_NEARBY ||
205
- tests.length > MAX_TESTS
206
- ? { truncated: true }
207
- : {}),
197
+ result: {
198
+ type: "tour",
199
+ entrypoints,
200
+ primaryFlow,
201
+ nearby: nearby.slice(0, MAX_NEARBY),
202
+ tests: tests.slice(0, MAX_TESTS),
203
+ answerAnchors,
204
+ ...(entry.truncated ||
205
+ primaryFlow.some((flow) => flow.truncated === true) ||
206
+ nearby.length > MAX_NEARBY ||
207
+ tests.length > MAX_TESTS
208
+ ? { truncated: true }
209
+ : {}),
210
+ },
211
+ next: tourNext(),
208
212
  };
209
213
  }
210
214
 
215
+ /**
216
+ * What the tour says it is.
217
+ *
218
+ * It used to say what it had _covered of the question_, and it could not know
219
+ * that. The claim was decided by matching the question's words against
220
+ * identifier names: a word the tour's own symbols did not spell, and for which
221
+ * some symbol somewhere in the graph did, came back as a stage the tour owed
222
+ * the reader — `inspect`, with the tool description telling the model to make
223
+ * exactly the one request it names.
224
+ *
225
+ * What that string match actually found, on the corpus: shopping-backend's
226
+ * question says "from request handling through auth, provider logic, Prisma",
227
+ * and the tour reported "handling" missing on the strength of `handlePayment`
228
+ * and `handleCancel`, two deposit and order helpers with nothing to do with
229
+ * handling a request. Vue's says "a component/template read", and "template"
230
+ * came back missing because the compiler's AST has a `TemplateLiteral`. Zod's
231
+ * ends "the returned result", and `ParseResult.data` made "result" a stage. In
232
+ * five of the eight project-specific tours the server reported a hole it did
233
+ * not have and spent the model a call on it — and the drill-down started there:
234
+ * shopping-backend's eight follow-ups open with the `lookup` this `next` asked
235
+ * for.
236
+ *
237
+ * The failure is not a threshold. A question names concepts and a graph holds
238
+ * identifiers, and no lexical rule bridges the two: "request handling" is not
239
+ * `handlePayment` and never will be. The server cannot know what the question
240
+ * means, so it cannot know whether it answered it — and the selection audit
241
+ * riding in the same payload says exactly that: each fact is compiler-verified,
242
+ * but the shortlist was ranked against the question and whether it covers the
243
+ * question is the caller's to judge.
244
+ *
245
+ * So the tour states what it returned, which is a fact, and leaves what to do
246
+ * with it to the reader, which was never the server's to decide. A tour that
247
+ * misses what the reader needs is a tour the reader keeps asking past — and the
248
+ * models do exactly that, without being told to.
249
+ */
250
+ function tourNext(): ITtscGraphNext {
251
+ return resultNext(
252
+ "answer",
253
+ "This is what the graph holds for the query: the entrypoints it ranked, the flows they run, the paths and tests around them, and the anchors to cite. Nothing in it needs verifying, and anything past it is another request.",
254
+ );
255
+ }
256
+
257
+ /**
258
+ * The symbols the caller named, as the graph knows them.
259
+ *
260
+ * A name is not a word. Ranked as text, the reading `setupRenderEffect` — the
261
+ * render effect Opus was asking about, and had written down — is shredded into
262
+ * "setup", "render", "effect", and the tour opens on `queuePostRenderEffect`
263
+ * instead. The model then traced `setupRenderEffect` by hand, which is the call
264
+ * the tour exists to save.
265
+ *
266
+ * So each entry is resolved the way a handle is: the graph either holds that
267
+ * symbol or it does not, and what it does not hold is dropped without ceremony.
268
+ * A phrase is not a name and resolves to nothing, so prose costs the tour
269
+ * nothing — which is what makes a guess free, and a repository the caller has
270
+ * never seen safe to guess about.
271
+ */
272
+ function namedNodesOf(
273
+ graph: TtscGraphMemory,
274
+ names: readonly string[],
275
+ ): ReadonlySet<string> {
276
+ const out = new Set<string>();
277
+ for (const raw of names) {
278
+ const name = raw.trim();
279
+ if (name.length === 0 || /\s/.test(name)) continue;
280
+ // An ambiguous guess is not evidence. A name the project declares once is a
281
+ // symbol the caller named; a name it declares many times is a word, and the
282
+ // graph does not get to decide which one was meant. Resolving to the first
283
+ // candidate decides anyway: Sonnet asked for `handlePointerDown`, Excalidraw
284
+ // declares it on several classes, and the tour of a drawing app opened on
285
+ // its line editor and cost eight calls. Boosting all of them instead only
286
+ // spreads the same guess wider. Both are the graph inventing a belief the
287
+ // caller did not have, so an ambiguous name is dropped, exactly like a name
288
+ // the graph has never heard of.
289
+ const resolved = resolveGraphHandle(graph, name);
290
+ if (resolved.node !== undefined) out.add(resolved.node.id);
291
+ }
292
+ return out;
293
+ }
294
+
211
295
  function tourSeedsOf(
212
296
  graph: TtscGraphMemory,
213
297
  entry: ITtscGraphEntrypoints,
214
298
  query: string,
215
299
  limit: number,
300
+ named: ReadonlySet<string>,
301
+ terms: string[],
216
302
  ): ITtscGraphNode[] {
217
303
  const out: ITtscGraphNode[] = [];
218
304
  const seen = new Set<string>();
@@ -222,13 +308,41 @@ function tourSeedsOf(
222
308
  seen.add(node.id);
223
309
  out.push(node);
224
310
  };
311
+ // A symbol the question names is an entrypoint of the tour, and a name the
312
+ // project declares more than once is not a name the project does not declare.
313
+ // Zod's question says `schema.parse`; the graph holds three `parse`s, so the
314
+ // mention came back as candidates rather than a node, and the tour dropped it
315
+ // and opened on `fromJSONSchema` — the model's first move was to go and trace
316
+ // `ZodType.parse` itself. The candidates arrive ranked by what the package
317
+ // publishes, and a tour is a ranked product: take the reading the ranking put
318
+ // first, which is the one a reader means.
225
319
  for (const mention of entry.mentions) {
226
- add(mention.node === undefined ? undefined : graph.node(mention.node.id));
320
+ const mentioned = mention.node ?? mention.candidates?.[0];
321
+ add(mentioned === undefined ? undefined : graph.node(mentioned.id));
227
322
  }
228
323
  if (hasExplicitSymbolHandle(query)) {
229
324
  for (const hit of entry.hits) add(graph.node(hit.id));
230
325
  }
231
- for (const node of rankedTourSeeds(graph, query)) add(node);
326
+ // The symbols the caller named get seats, and the graph's own centre keeps the
327
+ // rest. A name the *user* wrote is seeded outright, above — it is the symbol
328
+ // the question is about. A name the *caller* wrote is a belief about where the
329
+ // answer lives, which is worth a seat and is not worth the tour: the beliefs
330
+ // are ranked among themselves and take half the seeds, and centrality fills
331
+ // what is left. A caller that names nine symbols does not get nine names and
332
+ // no centre, and one that names the wrong symbol still gets a tour.
333
+ const share = Math.floor(limit * NAMED_SHARE);
334
+ if (share > 0 && named.size !== 0) {
335
+ for (const node of rankedTourSeeds(graph, terms, share, named)) add(node);
336
+ }
337
+ // The other half is the graph's, and the names do not reach it. Weighting the
338
+ // named symbols in this ranking too let them take both halves: Opus named ten
339
+ // symbols along RxJS's subscribe path, they filled the seeds, and `operate` —
340
+ // the second seed of the same tour without any names, and the head of the
341
+ // operator chain the question asked about — fell out of it. The model fetched
342
+ // it by hand and said so. A caller's belief about where the answer lives is
343
+ // worth half a tour; the other half is what the codebase says is central,
344
+ // including the symbol the caller did not think to name.
345
+ for (const node of rankedTourSeeds(graph, terms, limit)) add(node);
232
346
  if (out.length === 0) {
233
347
  for (const hit of entry.hits) add(graph.node(hit.id));
234
348
  }
@@ -241,6 +355,7 @@ function graphNodeOf(
241
355
  ): ITtscGraphTour.INode {
242
356
  const span = node.implementation ?? node.evidence;
243
357
  const signature = signatureOf(graph.project, node);
358
+ const doc = docOf(graph.project, node);
244
359
  const decorators = decoratorsOf(node);
245
360
  return {
246
361
  id: node.id,
@@ -260,11 +375,33 @@ function graphNodeOf(
260
375
  }
261
376
  : {}),
262
377
  ...(signature !== undefined ? { signature } : {}),
378
+ ...(doc !== undefined ? { doc } : {}),
263
379
  ...(decorators !== undefined ? { decorators } : {}),
264
380
  };
265
381
  }
266
382
 
267
- function traceNodeOf(node: ITtscGraphTrace.INode): ITtscGraphTour.INode {
383
+ /**
384
+ * A node a flow reached, as its handle and its line. Its span and signature are
385
+ * already in the flow's `steps` and `anchors`, and carrying them a second time
386
+ * cost half the tour's payload — enough that a client which caps a tool result
387
+ * spilled the whole thing to a file, and the model shelled out to read back its
388
+ * own answer.
389
+ *
390
+ * The file and the kind went the same way, for the same reason: a node id _is_
391
+ * `path/to/file.ts#Owner.member:kind`, so a reached node that also carried them
392
+ * bought one fact three times, and the flows are two thirds of a tour that is
393
+ * re-sent whole on every turn of the conversation it opened. The flow's start
394
+ * keeps the full node; the chain behind it does not need one.
395
+ */
396
+ function traceNodeOf(node: ITtscGraphTrace.INode): ITtscGraphTour.IReached {
397
+ return {
398
+ id: node.id,
399
+ name: node.name,
400
+ ...(node.line !== undefined ? { line: node.line } : {}),
401
+ };
402
+ }
403
+
404
+ function flowStartOf(node: ITtscGraphTrace.INode): ITtscGraphTour.INode {
268
405
  return {
269
406
  id: node.id,
270
407
  name: node.name,
@@ -300,19 +437,41 @@ function flowAnchorsOf(
300
437
  ]);
301
438
  }
302
439
 
440
+ /**
441
+ * The words of the question that could name code: the ranking's own terms.
442
+ *
443
+ * A question about TypeORM says "TypeORM", which splits into `type` and `orm`,
444
+ * and both are inside the project's own name — they say nothing about which
445
+ * part of the project is being asked about, while matching a hundred
446
+ * identifiers. Whole-word equality is not enough to drop them, so a term the
447
+ * project name contains goes too.
448
+ */
449
+ function queryTermsOf(
450
+ graph: TtscGraphMemory,
451
+ names: readonly string[],
452
+ ): string[] {
453
+ const project = graph.project.toLowerCase();
454
+ const out: string[] = [];
455
+ for (const name of names) {
456
+ for (const term of subwords(name)) {
457
+ if (term.length <= 2 || project.includes(term)) continue;
458
+ if (!out.includes(term)) out.push(term);
459
+ }
460
+ }
461
+ return out;
462
+ }
463
+
303
464
  function rankedTourSeeds(
304
465
  graph: TtscGraphMemory,
305
- query: string,
466
+ terms: string[],
467
+ count: number,
468
+ only?: ReadonlySet<string>,
306
469
  ): ITtscGraphNode[] {
307
- const projectTerms = new Set(subwords(graph.project));
308
- const terms = subwords(
309
- query.replace(/\btypescript\b/gi, "typescript"),
310
- ).filter(
311
- (term) =>
312
- term.length > 2 && !QUERY_STOP_WORDS.has(term) && !projectTerms.has(term),
313
- );
314
470
  const items = graph.nodes
315
- .filter((node) => isTourSeed(graph, node))
471
+ .filter(
472
+ (node) =>
473
+ isTourSeed(graph, node) && (only === undefined || only.has(node.id)),
474
+ )
316
475
  .map((node) => ({
317
476
  node,
318
477
  score: tourSeedScore(graph, node, terms),
@@ -320,35 +479,196 @@ function rankedTourSeeds(
320
479
  }))
321
480
  .filter((item) => item.score > 0)
322
481
  .sort((a, b) => b.score - a.score);
323
- return diverseTourSeeds(items, terms).map((item) => item.node);
482
+ return diverseTourSeeds(items, terms, count).map((item) => item.node);
324
483
  }
325
484
 
485
+ /**
486
+ * How central a symbol is to running this codebase, as one standard algorithm
487
+ * instead of a ledger of hand-tuned bonuses.
488
+ *
489
+ * This score used to be a sum: so many points for the symbol's kind, so many
490
+ * per log of each degree, a capped term for its reach, another for what its
491
+ * package exports, another for what the tests call — nine signals, each with a
492
+ * multiplier and a cap that someone picked while watching a benchmark. Word
493
+ * lists in numeric form. All of it was approximating one question the graph can
494
+ * answer exactly: _if you use what this package publishes, what runs?_
495
+ *
496
+ * Personalized PageRank answers it. The walker starts on the export surface —
497
+ * the symbols the package puts on the wire, members included — and follows the
498
+ * execution edges, crossing from an abstract declaration to its implementations
499
+ * the way `runTrace` does. Public entries hold mass because the walk starts on
500
+ * them; the spine holds mass because every path runs through it. One damping
501
+ * constant, 0.85, from the literature — the same algorithm aider's repo map
502
+ * ranks symbols with.
503
+ */
326
504
  function tourSeedScore(
327
505
  graph: TtscGraphMemory,
328
506
  node: ITtscGraphNode,
329
507
  terms: string[],
330
508
  ): number {
331
- const degree = realDegree(graph, node.id);
332
- const execution = executionDegree(graph, node.id);
333
509
  const queryWords = new Set(terms);
334
510
  const matchScore = queryMatchScore(node, terms);
335
- let score = kindScore(node.kind);
336
- const surface = entrySurfaceScore(node);
337
- score += surface;
338
- score += runtimeEntryScore(node, surface);
339
- score += Math.min(14, Math.log2(1 + degree.in) * 4);
340
- score += Math.min(30, Math.log2(1 + degree.out) * 9);
341
- score += Math.min(28, Math.log2(1 + execution.out) * 10);
342
- if (node.exported) score += 14;
343
- if (node.decorators !== undefined && node.decorators.length > 0) score += 10;
344
- score += matchScore;
511
+ let score = centralityOf(graph, node.id) + matchScore;
345
512
  score *= queryAlignmentFactor(matchScore, queryWords);
346
- score *= broadTourDamping(node, queryWords);
513
+ score *= broadTourDamping(node);
347
514
  return score;
348
515
  }
349
516
 
517
+ /** Centrality is reported on a 0..100 scale: 100 is this graph's most central. */
518
+ const CENTRALITY_SCALE = 100;
519
+ /** Execution reach is walked this deep and no further. */
520
+ const REACH_DEPTH = 4;
521
+ const REACH_NODE_BUDGET = 400;
522
+ /** Invocation: the edges that mean "this makes that run". */
523
+ const INVOKE_KINDS = new Set<string>(["calls", "instantiates", "renders"]);
524
+ const DISPATCH_KINDS = new Set<string>(["overrides", "implements"]);
525
+
526
+ const centralityCache = new WeakMap<TtscGraphMemory, Map<string, number>>();
527
+
528
+ function centralityOf(graph: TtscGraphMemory, id: string): number {
529
+ let ranks = centralityCache.get(graph);
530
+ if (ranks === undefined) {
531
+ ranks = computeCentrality(graph);
532
+ centralityCache.set(graph, ranks);
533
+ }
534
+ return ranks.get(id) ?? 0;
535
+ }
536
+
537
+ /**
538
+ * Three facts, one product: `log2(1 + published) * max(reach, fan-in)`.
539
+ *
540
+ * - _Published_: how many modules put the symbol on the wire, counting the class
541
+ * that owns it — a member is published by publishing its owner.
542
+ * - _Reach_: how many production files its forward invocation chain touches,
543
+ * crossing from an abstract declaration to the implementations, the way
544
+ * execution does. This is what separates an entry that drives half the
545
+ * framework from a fat method whose fifty calls stay in its own file.
546
+ * - _Fan-in_: how many production call sites invoke it. This is what separates
547
+ * the spine everything runs through from a leaf beside it.
548
+ *
549
+ * The product means a symbol must be public _and_ load-bearing: a utility is
550
+ * published everywhere but drives nothing and dies on the max term's
551
+ * normalization; an internal engine drives everything but is published nowhere
552
+ * and dies on the surface term. Tests and generated support files are not part
553
+ * of the structure being toured and stay out of all three.
554
+ */
555
+ function computeCentrality(graph: TtscGraphMemory): Map<string, number> {
556
+ const production = (node: ITtscGraphNode): boolean =>
557
+ !node.external &&
558
+ !node.ignored &&
559
+ !isTestPath(node.file) &&
560
+ !isNoisePath(node.file);
561
+
562
+ const invoked = (id: string): string[] => {
563
+ const out: string[] = [];
564
+ let hasBody = false;
565
+ for (const edge of graph.outgoing(id)) {
566
+ if (!INVOKE_KINDS.has(edge.kind)) continue;
567
+ hasBody = true;
568
+ out.push(edge.to);
569
+ }
570
+ if (!hasBody) {
571
+ for (const edge of graph.incoming(id)) {
572
+ if (DISPATCH_KINDS.has(edge.kind)) out.push(edge.from);
573
+ }
574
+ }
575
+ return out;
576
+ };
577
+
578
+ const reachOf = (id: string): number => {
579
+ const seen = new Set<string>([id]);
580
+ const files = new Set<string>();
581
+ let frontier = [id];
582
+ for (
583
+ let depth = 0;
584
+ depth < REACH_DEPTH && seen.size < REACH_NODE_BUDGET;
585
+ depth++
586
+ ) {
587
+ const next: string[] = [];
588
+ for (const current of frontier) {
589
+ for (const to of invoked(current)) {
590
+ if (seen.has(to)) continue;
591
+ const target = graph.node(to);
592
+ if (target === undefined || !production(target)) continue;
593
+ seen.add(to);
594
+ files.add(target.file);
595
+ next.push(to);
596
+ }
597
+ }
598
+ if (next.length === 0) break;
599
+ frontier = next;
600
+ }
601
+ return files.size;
602
+ };
603
+
604
+ const out = new Map<string, number>();
605
+ const candidates: { id: string; surface: number; fanIn: number }[] = [];
606
+ for (const node of graph.nodes) {
607
+ if (!production(node)) continue;
608
+ const surface = publicFanIn(graph, node.id);
609
+ if (surface <= 0) continue;
610
+ let fanIn = 0;
611
+ for (const edge of graph.incoming(node.id)) {
612
+ if (!INVOKE_KINDS.has(edge.kind)) continue;
613
+ const caller = graph.node(edge.from);
614
+ if (caller !== undefined && production(caller)) fanIn++;
615
+ }
616
+ candidates.push({ id: node.id, surface, fanIn });
617
+ }
618
+
619
+ let reachMax = 1;
620
+ let fanInMax = 1;
621
+ const reaches = new Map<string, number>();
622
+ for (const candidate of candidates) {
623
+ const reach = reachOf(candidate.id);
624
+ reaches.set(candidate.id, reach);
625
+ if (reach > reachMax) reachMax = reach;
626
+ if (candidate.fanIn > fanInMax) fanInMax = candidate.fanIn;
627
+ }
628
+
629
+ let max = 0;
630
+ const raw = new Map<string, number>();
631
+ for (const candidate of candidates) {
632
+ const load = Math.max(
633
+ (reaches.get(candidate.id) ?? 0) / reachMax,
634
+ candidate.fanIn / fanInMax,
635
+ );
636
+ const score = Math.log2(1 + candidate.surface) * load;
637
+ raw.set(candidate.id, score);
638
+ if (score > max) max = score;
639
+ }
640
+ if (max > 0) {
641
+ for (const [id, score] of raw)
642
+ out.set(id, (score / max) * CENTRALITY_SCALE);
643
+ }
644
+ return out;
645
+ }
646
+
647
+ /**
648
+ * A tour ranks and walks the project's surface.
649
+ *
650
+ * Not because a closure is beneath an index — a trace, a lookup, or a details
651
+ * request answers with one, and that is what the specific-flow lane needed. It
652
+ * is because the seed score leans on reach, and reach breaks when it counts
653
+ * them. Reach stands in for "gets to the code that does the work", and a method
654
+ * whose body is full of callbacks lands in more files than one that calls three
655
+ * things and means them: TypeORM's `SelectQueryBuilder` outranked its insert
656
+ * path on breadth alone, and the tour it led came back a walk through the query
657
+ * builder's fluent API — escape, clone, addSelect, limit, offset — while the
658
+ * insert flow that reaches the broadcaster, the metadata, and the driver fell
659
+ * out of the tour entirely. Wide and shallow beat deep and few, and the model
660
+ * went back to the files.
661
+ *
662
+ * So the surface is scored by the surface, and the body is answered when it is
663
+ * asked for. The specific-flow lane wants the closures and gets them; the
664
+ * orientation lane wants the surface and gets that. Judged by the answer rather
665
+ * than the token count, the gated tour is the better one: it reaches the
666
+ * broadcaster and the driver, where the ungated tour reached `limit` and
667
+ * `offset`.
668
+ */
350
669
  function isTourSeed(graph: TtscGraphMemory, node: ITtscGraphNode): boolean {
351
670
  return (
671
+ node.closure !== true &&
352
672
  TOUR_SEED_KINDS.has(node.kind) &&
353
673
  (node.kind !== "property" || executionDegree(graph, node.id).out > 0) &&
354
674
  !node.external &&
@@ -366,8 +686,30 @@ function flowSeedIdsOf(seeds: ITtscGraphNode[]): string[] {
366
686
  return source.map((node) => node.id);
367
687
  }
368
688
 
369
- function isTourTraceNode(node: ITtscGraphTrace.INode): boolean {
370
- return !isNoisePath(node.file);
689
+ /**
690
+ * True when two flows land in mostly the same places — the same story told
691
+ * twice. Overlap is measured against the smaller flow, so a short chain fully
692
+ * contained in a longer one counts as told, which is what a sibling entry
693
+ * (`parse` beside `safeParse`) actually is.
694
+ */
695
+ function overlaps(candidate: Set<string>, told: Set<string>): boolean {
696
+ const smaller = candidate.size <= told.size ? candidate : told;
697
+ const larger = smaller === candidate ? told : candidate;
698
+ if (smaller.size === 0) return true;
699
+ let shared = 0;
700
+ for (const id of smaller) if (larger.has(id)) shared++;
701
+ return shared / smaller.size >= FLOW_OVERLAP;
702
+ }
703
+
704
+ function isTourTraceNode(
705
+ graph: TtscGraphMemory,
706
+ node: ITtscGraphTrace.INode,
707
+ ): boolean {
708
+ return (
709
+ graph.node(node.id)?.closure !== true &&
710
+ !isNoisePath(node.file) &&
711
+ !isSharedUtility(graph, node.id)
712
+ );
371
713
  }
372
714
 
373
715
  function isTourHop(graph: TtscGraphMemory, hop: ITtscGraphTrace.IHop): boolean {
@@ -376,12 +718,31 @@ function isTourHop(graph: TtscGraphMemory, hop: ITtscGraphTrace.IHop): boolean {
376
718
  return (
377
719
  from !== undefined &&
378
720
  to !== undefined &&
721
+ from.closure !== true &&
722
+ to.closure !== true &&
379
723
  !STRUCTURAL_KINDS.has(hop.kind) &&
380
724
  !isNoisePath(from.file) &&
381
- !isNoisePath(to.file)
725
+ !isNoisePath(to.file) &&
726
+ !isSharedUtility(graph, hop.to)
382
727
  );
383
728
  }
384
729
 
730
+ // A fan-in hub that drives no further execution: reached from a dozen-plus
731
+ // sites yet calling nothing onward (a shared type, guard, or leaf helper). It is
732
+ // a terminus, not a step in the runtime call chain, so the tour drops it from
733
+ // the flow to keep the meaningful chain legible — it still surfaces as a seed,
734
+ // nearby node, or detail when it is itself the subject.
735
+ //
736
+ // The `in >= 12` cut is not a fixture-tuned constant: because real-in-degree is
737
+ // heavy-tailed, this fixed count lands at the ~93rd percentile of fan-in across
738
+ // every benchmark project (900–16k nodes, 92.4–95.5%), so it selects the same
739
+ // "top few percent of hubs" band regardless of project size, while the absolute
740
+ // floor makes it a no-op on small graphs that have no genuine hub. The `out <= 1`
741
+ // guard keeps thin pass-throughs out but never prunes a real branching step.
742
+ function isSharedUtility(graph: TtscGraphMemory, id: string): boolean {
743
+ return realDegree(graph, id).in >= 12 && executionDegree(graph, id).out <= 1;
744
+ }
745
+
385
746
  function flowStepOf(graph: TtscGraphMemory, hop: ITtscGraphTrace.IHop): string {
386
747
  const from = graph.node(hop.from);
387
748
  const to = graph.node(hop.to);
@@ -393,6 +754,19 @@ function flowStepOf(graph: TtscGraphMemory, hop: ITtscGraphTrace.IHop): string {
393
754
  return `${lhs} -[${hop.kind}${at}]-> ${rhs}`;
394
755
  }
395
756
 
757
+ /**
758
+ * True when the node at the other end of an edge is a closure.
759
+ *
760
+ * A tour scores the surface, and a closure is not on it — but a closure's edges
761
+ * still land on surface nodes, and counted there they move the score of the
762
+ * very declarations a tour ranks. Keeping closures out of the seed list was not
763
+ * enough: TypeORM's tour still traded its insert flow for a walk through the
764
+ * query builder's fluent API. The surface is scored by the surface.
765
+ */
766
+ function touchesClosure(graph: TtscGraphMemory, id: string): boolean {
767
+ return graph.node(id)?.closure === true;
768
+ }
769
+
396
770
  function realDegree(
397
771
  graph: TtscGraphMemory,
398
772
  id: string,
@@ -403,9 +777,11 @@ function realDegree(
403
777
  let incoming = 0;
404
778
  let outgoing = 0;
405
779
  for (const edge of graph.outgoing(id))
406
- if (!STRUCTURAL_KINDS.has(edge.kind)) outgoing++;
780
+ if (!STRUCTURAL_KINDS.has(edge.kind) && !touchesClosure(graph, edge.to))
781
+ outgoing++;
407
782
  for (const edge of graph.incoming(id))
408
- if (!STRUCTURAL_KINDS.has(edge.kind)) incoming++;
783
+ if (!STRUCTURAL_KINDS.has(edge.kind) && !touchesClosure(graph, edge.from))
784
+ incoming++;
409
785
  return { in: incoming, out: outgoing };
410
786
  }
411
787
 
@@ -419,115 +795,78 @@ function executionDegree(
419
795
  let incoming = 0;
420
796
  let outgoing = 0;
421
797
  for (const edge of graph.outgoing(id))
422
- if (EXECUTION_KINDS.has(edge.kind)) outgoing++;
798
+ if (EXECUTION_KINDS.has(edge.kind) && !touchesClosure(graph, edge.to))
799
+ outgoing++;
423
800
  for (const edge of graph.incoming(id))
424
- if (EXECUTION_KINDS.has(edge.kind)) incoming++;
801
+ if (EXECUTION_KINDS.has(edge.kind) && !touchesClosure(graph, edge.from))
802
+ incoming++;
425
803
  return { in: incoming, out: outgoing };
426
804
  }
427
805
 
428
- function kindScore(kind: string): number {
429
- switch (kind) {
430
- case "function":
431
- case "method":
432
- return 28;
433
- case "property":
434
- case "variable":
435
- return 8;
436
- case "class":
437
- return 24;
438
- case "module":
439
- case "namespace":
440
- return 16;
441
- case "enum":
442
- return 10;
443
- default:
444
- return 0;
445
- }
446
- }
806
+ /**
807
+ * What the declaration is, scored by what it does rather than how it was
808
+ * written. `export const parse = (input) => ...` is a function that happens to
809
+ * be bound to a name, and the checker sees it call things; scoring it eight
810
+ * points against a method's twenty-eight is a bias toward one syntax, and it
811
+ * cost zod its own public API — `parse` and `safeParse`, both const arrows,
812
+ * lost their tour seats to `ZodType.safeParse`, a method of the previous
813
+ * major.
814
+ */
447
815
 
448
- function entrySurfaceScore(node: ITtscGraphNode): number {
449
- const file = node.file.replace(/\\/g, "/");
450
- const base = file.slice(file.lastIndexOf("/") + 1).toLowerCase();
451
- const stem = base.replace(/\.[cm]?[tj]sx?$/, "");
452
- const depth = sourceDepth(file);
453
- let score = 0;
454
- if (stem === "index") {
455
- if (depth <= 0) score += 48;
456
- else if (depth === 1) score += 32;
457
- else if (depth === 2) score += 12;
458
- } else if (stem === "main" || stem === "server" || stem === "bootstrap")
459
- score += 42;
460
- else if (stem === "app" || stem === "application") score += 28;
461
-
462
- if (depth <= 1) score += 22;
463
- else if (depth === 2) score += 12;
464
- else if (depth === 3) score += 5;
465
-
466
- if (node.exported && score > 0) score += 12;
467
- return score;
816
+ /**
817
+ * How many modules put a symbol on the wire — counting the one that owns it.
818
+ *
819
+ * A method is not exported; its class is. `Observable.subscribe` and
820
+ * `ZodType.parse` carry an export fan-in of zero, because nothing re-exports a
821
+ * member: what a package publishes is `Observable`, and calling `subscribe` on
822
+ * it is what publishing `Observable` was for. Scored on its own fan-in, the
823
+ * public method a whole library exists to be called through ranks below every
824
+ * loose function beside it, and a list of English verbs — `parse`, `subscribe`,
825
+ * `render` was what used to put it back.
826
+ *
827
+ * A member inherits the surface of what contains it. That is an edge the graph
828
+ * already draws, and it says the same thing in a codebase whose classes are
829
+ * named in Japanese.
830
+ */
831
+ function publicFanIn(graph: TtscGraphMemory, id: string): number {
832
+ const own = exportFanIn(graph, id);
833
+ const owner = ownerOf(graph, id);
834
+ return owner === undefined ? own : Math.max(own, exportFanIn(graph, owner));
468
835
  }
469
836
 
470
- function runtimeEntryScore(node: ITtscGraphNode, surface: number): number {
471
- const words = new Set([
472
- ...subwords(node.name),
473
- ...subwords(node.qualifiedName ?? ""),
474
- ]);
475
- if (isPrivateLike(node, words)) return 0;
476
- const hasVerb = hasAny(words, [
477
- "bootstrap",
478
- "create",
479
- "execute",
480
- "handle",
481
- "init",
482
- "initialize",
483
- "listen",
484
- "mount",
485
- "open",
486
- "parse",
487
- "render",
488
- "run",
489
- "safe",
490
- "safeparse",
491
- "start",
492
- "startup",
493
- "subscribe",
494
- "update",
495
- ]);
496
- if (node.kind === "method" && hasVerb) return 90;
497
- if (
498
- (node.kind === "function" ||
499
- node.kind === "property" ||
500
- node.kind === "variable") &&
501
- surface > 0 &&
502
- hasVerb
503
- ) {
504
- return 70;
505
- }
506
- if (
507
- node.kind === "class" &&
508
- hasAny(words, [
509
- "application",
510
- "app",
511
- "backend",
512
- "client",
513
- "datasource",
514
- "factory",
515
- "server",
516
- ])
517
- ) {
518
- return 45;
837
+ function ownerOf(graph: TtscGraphMemory, id: string): string | undefined {
838
+ for (const edge of graph.incoming(id)) {
839
+ if (edge.kind !== "contains") continue;
840
+ const owner = graph.node(edge.from);
841
+ if (
842
+ owner !== undefined &&
843
+ owner.kind !== "file" &&
844
+ owner.kind !== "module"
845
+ ) {
846
+ return owner.id;
847
+ }
519
848
  }
520
- return 0;
849
+ return undefined;
521
850
  }
522
851
 
523
- function sourceDepth(file: string): number {
524
- const parts = file.split("/").filter(Boolean);
525
- if (parts[0] === "src") return Math.max(0, parts.length - 2);
526
- if (parts[0] === "packages" && parts.length >= 3) {
527
- return Math.max(0, parts.length - 3);
528
- }
529
- return Math.max(0, parts.length - 1);
530
- }
852
+ /**
853
+ * What a user of this package can call, as the graph knows it.
854
+ *
855
+ * This used to pay for an English verb anywhere in the name — `create`,
856
+ * `parse`, `render`, `subscribe` — and for a class whose name contained `app`,
857
+ * `server` or `factory`. Neither is a fact about the code. They are a guess
858
+ * about the language its authors happened to write in, and a codebase that
859
+ * names its entry `起動` or `mk` or `boot` is one the guess is simply wrong
860
+ * about. It was also wrong in English: `onRenderTracked` is a devtools hook and
861
+ * it took the bonus for the "render" inside it, outranking `track`, the
862
+ * function it is named after.
863
+ *
864
+ * Two facts say the same thing without reading a word of the name. The package
865
+ * _publishes_ this symbol — that is its export surface, counted in
866
+ * {@link exportFanIn} — and it is a _callable_, so publishing it is publishing
867
+ * something to run. A user calls what a package exports and what a package
868
+ * exports to be called.
869
+ */
531
870
 
532
871
  function queryMatchScore(node: ITtscGraphNode, terms: string[]): number {
533
872
  return (
@@ -563,18 +902,34 @@ function matchedTerms(words: string[], terms: string[]): Set<string> {
563
902
  return matched;
564
903
  }
565
904
 
905
+ /**
906
+ * Greedy set cover over the query's terms: each pick is the highest-scoring
907
+ * candidate that still covers a term no pick covers yet, so the seeds spread
908
+ * across the question instead of crowding onto its loudest word.
909
+ *
910
+ * It picks `count` of them, not all of them. Ordering every candidate cost
911
+ * O(n²) — on VS Code, where tens of thousands of symbols score above zero, one
912
+ * tour spent six minutes ranking seeds it then threw away, because the caller
913
+ * keeps only the first few. Stopping at `count` makes the cover O(count · n),
914
+ * and the picks it does make are the same ones.
915
+ */
566
916
  function diverseTourSeeds<
567
- T extends { score: number; matchedTerms: Set<string> },
568
- >(items: T[], terms: string[]): T[] {
569
- if (items.length <= 1 || terms.length === 0) return items;
917
+ T extends {
918
+ node: ITtscGraphNode;
919
+ score: number;
920
+ matchedTerms: Set<string>;
921
+ },
922
+ >(items: T[], terms: string[], count: number): T[] {
923
+ if (items.length <= 1 || terms.length === 0) return items.slice(0, count);
570
924
  const out: T[] = [];
571
925
  const remaining = [...items];
572
926
  const uncovered = new Set(terms);
573
- while (remaining.length > 0) {
574
- let bestIndex = 0;
927
+ while (remaining.length > 0 && out.length < count) {
928
+ let bestIndex = -1;
575
929
  let bestScore = Number.NEGATIVE_INFINITY;
576
930
  for (let i = 0; i < remaining.length; i++) {
577
931
  const item = remaining[i]!;
932
+ if (out.some((picked) => restates(picked.node, item.node))) continue;
578
933
  let coverage = 0;
579
934
  for (const term of item.matchedTerms) if (uncovered.has(term)) coverage++;
580
935
  const score = coverage * 120 + item.score;
@@ -583,6 +938,7 @@ function diverseTourSeeds<
583
938
  bestIndex = i;
584
939
  }
585
940
  }
941
+ if (bestIndex === -1) break;
586
942
  const [picked] = remaining.splice(bestIndex, 1);
587
943
  out.push(picked!);
588
944
  for (const term of picked!.matchedTerms) uncovered.delete(term);
@@ -590,6 +946,30 @@ function diverseTourSeeds<
590
946
  return out;
591
947
  }
592
948
 
949
+ /**
950
+ * Whether a candidate seed would only say again what a chosen seed says: the
951
+ * same file, and a name the chosen one already contains word for word.
952
+ *
953
+ * `LinearElementEditor.handlePointerMove` and its `...InEditMode` sibling, and
954
+ * `renderNewElementScene` beside its own throttled twin, took four of the five
955
+ * seeds on Excalidraw's edit-pipeline tour. The mutation and history layers the
956
+ * question named took none, and Sonnet spent twenty-two graph calls finding
957
+ * them. A seed that restates a chosen one is a slot spent on a fact the tour
958
+ * already has.
959
+ */
960
+ function restates(chosen: ITtscGraphNode, candidate: ITtscGraphNode): boolean {
961
+ if (chosen.file !== candidate.file) return false;
962
+ const chosenWords = subwords(chosen.name).map(stemWord);
963
+ const candidateWords = subwords(candidate.name).map(stemWord);
964
+ const [shorter, longer] =
965
+ chosenWords.length <= candidateWords.length
966
+ ? [chosenWords, candidateWords]
967
+ : [candidateWords, chosenWords];
968
+ return (
969
+ shorter.length > 0 && shorter.every((word, index) => longer[index] === word)
970
+ );
971
+ }
972
+
593
973
  function queryAlignmentFactor(
594
974
  matchScore: number,
595
975
  queryWords: ReadonlySet<string>,
@@ -600,89 +980,33 @@ function queryAlignmentFactor(
600
980
  return 0.45;
601
981
  }
602
982
 
603
- function broadTourDamping(
604
- node: ITtscGraphNode,
605
- queryWords: ReadonlySet<string>,
606
- ): number {
607
- const words = new Set([
608
- ...subwords(node.name),
609
- ...subwords(node.qualifiedName ?? ""),
610
- ...subwords(node.file),
611
- ]);
612
- let factor = 1;
613
- if (
614
- !hasAny(queryWords, ["internal", "private"]) &&
615
- isPrivateLike(node, words)
616
- ) {
617
- factor *= 0.25;
618
- }
619
- if (
620
- !hasAny(queryWords, ["error", "errors", "exception", "exceptions"]) &&
621
- hasAny(words, ["error", "errors", "exception", "exceptions"])
622
- ) {
623
- factor *= 0.25;
624
- }
625
- if (
626
- !hasAny(queryWords, [
627
- "config",
628
- "configuration",
629
- "env",
630
- "environment",
631
- "option",
632
- "options",
633
- "port",
634
- ]) &&
635
- (node.kind === "variable" || node.kind === "property") &&
636
- hasAny(words, [
637
- "config",
638
- "configuration",
639
- "env",
640
- "environment",
641
- "option",
642
- "options",
643
- "port",
644
- ])
645
- ) {
646
- factor *= 0.35;
647
- }
648
- if (
649
- !hasAny(queryWords, [
650
- "deserialize",
651
- "deserializer",
652
- "serializer",
653
- "serialize",
654
- "serialization",
655
- ]) &&
656
- hasAny(words, [
657
- "deserialize",
658
- "deserializer",
659
- "serializer",
660
- "serialize",
661
- "serialization",
662
- ])
663
- ) {
664
- factor *= 0.25;
665
- }
666
- return factor;
667
- }
668
-
669
- function hasAny(
670
- words: ReadonlySet<string>,
671
- candidates: readonly string[],
672
- ): boolean {
673
- return candidates.some((word) => words.has(word));
983
+ /**
984
+ * What a tour demotes, without reading a word of the name.
985
+ *
986
+ * This used to hold four lists of English words -- error, exception, config,
987
+ * env, option, port, serialize, deserialize, internal, private -- and it
988
+ * quartered the score of any symbol whose name contained one, unless the
989
+ * question contained it too. A codebase that names its errors `エラー` was never
990
+ * damped, and a question asked in Japanese never lifted the damping. The lists
991
+ * were a guess about the language, not a fact about the code.
992
+ *
993
+ * A leading underscore stays: it is punctuation, and it means the same thing in
994
+ * every language a symbol can be named in. Everything else a tour used to
995
+ * demote by vocabulary -- a config bag, an error type, a serializer -- the
996
+ * score already demotes by structure: they run nothing, so they carry no
997
+ * execution degree and no execution reach.
998
+ */
999
+ function broadTourDamping(node: ITtscGraphNode): number {
1000
+ return isPrivateLike(node) ? 0.25 : 1;
674
1001
  }
675
1002
 
676
- function isPrivateLike(
677
- node: ITtscGraphNode,
678
- words: ReadonlySet<string>,
679
- ): boolean {
1003
+ function isPrivateLike(node: ITtscGraphNode): boolean {
1004
+ // The leading underscore is punctuation, not vocabulary: it means the same
1005
+ // in a codebase whose symbols are named in Japanese as in one named in
1006
+ // English. The words that used to sit here -- "inner", "internal",
1007
+ // "private" -- did not.
680
1008
  const name = node.qualifiedName ?? node.name;
681
- return (
682
- name.startsWith("_") ||
683
- name.includes("._") ||
684
- hasAny(words, ["inner", "internal", "private"])
685
- );
1009
+ return name.startsWith("_") || name.includes("._");
686
1010
  }
687
1011
 
688
1012
  function hasExplicitSymbolHandle(query: string): boolean {
@@ -692,6 +1016,20 @@ function hasExplicitSymbolHandle(query: string): boolean {
692
1016
  );
693
1017
  }
694
1018
 
1019
+ /** How many leading path segments two files share. */
1020
+ function sharedPathDepth(a: string, b: string): number {
1021
+ const left = a.split("/");
1022
+ const right = b.split("/");
1023
+ let shared = 0;
1024
+ while (
1025
+ shared < left.length - 1 &&
1026
+ shared < right.length - 1 &&
1027
+ left[shared] === right[shared]
1028
+ )
1029
+ shared++;
1030
+ return shared;
1031
+ }
1032
+
695
1033
  function isNoisePath(file: string): boolean {
696
1034
  return isSupportPath(file);
697
1035
  }
@@ -705,13 +1043,40 @@ function subwords(text: string): string[] {
705
1043
  .map((word) => word.toLowerCase());
706
1044
  }
707
1045
 
1046
+ /**
1047
+ * A question and the code it asks about name the same thing in different parts
1048
+ * of speech: the asker writes "scene mutation", the symbol is `mutateElement`.
1049
+ * Inflection alone does not bridge that — "mutation" and "mutate" share five
1050
+ * characters and the prefix rule wants six — so a tour of Excalidraw's edit
1051
+ * pipeline seeded four renderers, never surfaced the mutation layer the
1052
+ * question named, and Sonnet went and found it itself in twenty-one further
1053
+ * graph calls.
1054
+ *
1055
+ * Stripping the derivational suffixes as well collapses both spellings onto the
1056
+ * same stem, so the noun in the question reaches the verb in the code.
1057
+ */
708
1058
  function stemWord(word: string): string {
709
- for (const suffix of ["ing", "ed", "es", "s"]) {
1059
+ for (const suffix of [
1060
+ "ation",
1061
+ "ing",
1062
+ "ment",
1063
+ "ence",
1064
+ "ance",
1065
+ "ion",
1066
+ "ity",
1067
+ "ed",
1068
+ "es",
1069
+ "s",
1070
+ ]) {
710
1071
  if (word.length > suffix.length + 3 && word.endsWith(suffix)) {
711
- return word.slice(0, -suffix.length);
1072
+ return trimTrailingE(word.slice(0, -suffix.length));
712
1073
  }
713
1074
  }
714
- return word;
1075
+ return trimTrailingE(word);
1076
+ }
1077
+
1078
+ function trimTrailingE(word: string): string {
1079
+ return word.length > 4 && word.endsWith("e") ? word.slice(0, -1) : word;
715
1080
  }
716
1081
 
717
1082
  function commonPrefixLength(a: string, b: string): number {
@@ -721,17 +1086,40 @@ function commonPrefixLength(a: string, b: string): number {
721
1086
  return i;
722
1087
  }
723
1088
 
1089
+ /**
1090
+ * The code paths around each selected symbol: what runs it, what it runs, and
1091
+ * what it is declared against — in that order, once each.
1092
+ *
1093
+ * `dependsOn` is the union of what a symbol calls and what it names in a type
1094
+ * position, so walking `calls`, then `types`, then `dependsOn` listed the same
1095
+ * neighbour under three labels, and the ten nearby slots of Excalidraw's edit
1096
+ * tour went: `_renderInteractiveScene` as a call, `_renderInteractiveScene` as
1097
+ * a type, `InteractiveSceneRenderConfig` as a type, and the same again for the
1098
+ * next symbol. Two of five stages consumed the whole list, and the stage the
1099
+ * reader would have to look up next — who calls the mutation — was not in it.
1100
+ * Sonnet then asked the graph "who calls this" thirteen times.
1101
+ *
1102
+ * So a neighbour is named once, and the callers come first. A tour follows what
1103
+ * runs; a type reference is the weakest thing a symbol can say about itself,
1104
+ * and it goes last, where the cap can drop it without dropping a call path.
1105
+ */
724
1106
  function nearbyAnchorsOf(details: ITtscGraphDetails): ITtscGraphTour.IAnchor[] {
725
- const anchors: ITtscGraphTour.IAnchor[] = [];
726
- for (const node of details.nodes) {
727
- anchors.push(...anchorFromNode("selected symbol", detailNodeOf(node)));
1107
+ const perNode = details.nodes.map((node) => {
1108
+ // The selected symbol is not near itself. It is an entrypoint, with its
1109
+ // span, its signature and its doc, and it is an answer anchor under that
1110
+ // name — a third copy here spent half the nearby list saying what the top
1111
+ // of the tour already said.
1112
+ const anchors: ITtscGraphTour.IAnchor[] = [];
1113
+ const named = new Set<string>([node.name]);
728
1114
  for (const ref of [
1115
+ ...(node.dependedOnBy ?? []),
729
1116
  ...(node.calls ?? []),
730
- ...(node.types ?? []),
731
1117
  ...(node.implementedBy ?? []),
732
1118
  ...(node.dependsOn ?? []),
733
- ...(node.dependedOnBy ?? []),
1119
+ ...(node.types ?? []),
734
1120
  ]) {
1121
+ if (named.has(ref.name)) continue;
1122
+ named.add(ref.name);
735
1123
  anchors.push(
736
1124
  ...anchorFromEvidence(
737
1125
  `${ref.relation} ${ref.name}`,
@@ -740,60 +1128,73 @@ function nearbyAnchorsOf(details: ITtscGraphDetails): ITtscGraphTour.IAnchor[] {
740
1128
  ),
741
1129
  );
742
1130
  }
743
- }
744
- return uniqueAnchors(anchors);
745
- }
1131
+ return anchors;
1132
+ });
746
1133
 
747
- function detailNodeOf(node: ITtscGraphDetails.INode): ITtscGraphTour.INode {
748
- return {
749
- id: node.id,
750
- name: node.name,
751
- kind: node.kind,
752
- file: node.file,
753
- ...(node.line !== undefined ? { line: node.line } : {}),
754
- ...(node.sourceSpan !== undefined
755
- ? {
756
- sourceSpan: {
757
- file: node.sourceSpan.file,
758
- startLine: node.sourceSpan.startLine,
759
- ...(node.sourceSpan.endLine !== undefined
760
- ? { endLine: node.sourceSpan.endLine }
761
- : {}),
762
- },
763
- }
764
- : {}),
765
- ...(node.signature !== undefined ? { signature: node.signature } : {}),
766
- ...(node.decorators !== undefined ? { decorators: node.decorators } : {}),
767
- };
1134
+ // A stage at a time, not a symbol at a time. The list is capped, and taken
1135
+ // symbol by symbol the first one's neighbourhood filled it: Excalidraw's
1136
+ // renderer spent six of the ten slots on its own callees and types, and the
1137
+ // mutation, the history and the collaboration the question named got none.
1138
+ const anchors: ITtscGraphTour.IAnchor[] = [];
1139
+ const told = new Set<string>();
1140
+ const depth = Math.max(0, ...perNode.map((list) => list.length));
1141
+ for (let index = 0; index < depth; index++)
1142
+ for (const list of perNode) {
1143
+ const anchor = list[index];
1144
+ if (anchor === undefined || told.has(anchor.name)) continue;
1145
+ told.add(anchor.name);
1146
+ anchors.push(anchor);
1147
+ }
1148
+ return uniqueAnchors(anchors);
768
1149
  }
769
1150
 
1151
+ /**
1152
+ * The tests that exercise the tour's symbols, nearest first.
1153
+ *
1154
+ * A subject is covered by more than one suite: NestJS's
1155
+ * `NestFactoryStatic.create` is called by three GraphQL end-to-end specs under
1156
+ * integration/ and by the unit spec that sits beside the code, and the tour's
1157
+ * slots went to whichever the edge list happened to hold first — the e2e ones.
1158
+ * So the model globbed the disk for `packages/core/test/nest-factory.spec.ts`,
1159
+ * which the graph had all along. A test that lives next to its subject is the
1160
+ * one a newcomer reads, so the anchors come back ordered by how much of the
1161
+ * subject's path the test shares.
1162
+ */
770
1163
  function testAnchorsOf(
771
1164
  graph: TtscGraphMemory,
772
1165
  seedIds: string[],
773
1166
  ): ITtscGraphTour.IAnchor[] {
774
1167
  const anchors: ITtscGraphTour.IAnchor[] = [];
775
1168
  for (const id of seedIds) {
1169
+ const subject = graph.node(id);
1170
+ const near: Array<{
1171
+ proximity: number;
1172
+ anchors: ITtscGraphTour.IAnchor[];
1173
+ }> = [];
776
1174
  for (const edge of graph.incoming(id)) {
777
1175
  const node = graph.node(edge.from);
778
1176
  if (node === undefined || !isTestPath(node.file)) continue;
779
- anchors.push(
780
- ...anchorFromNode("test coverage", graphNodeOf(graph, node)),
781
- );
782
- anchors.push(
783
- ...anchorFromEvidence(
784
- `${edge.kind} ${node.qualifiedName ?? node.name}`,
785
- node.qualifiedName ?? node.name,
786
- edge.evidence,
787
- ),
788
- );
1177
+ near.push({
1178
+ proximity: sharedPathDepth(subject?.file ?? "", node.file),
1179
+ anchors: [
1180
+ ...anchorFromNode("test coverage", graphNodeOf(graph, node)),
1181
+ ...anchorFromEvidence(
1182
+ `${edge.kind} ${node.qualifiedName ?? node.name}`,
1183
+ node.qualifiedName ?? node.name,
1184
+ edge.evidence,
1185
+ ),
1186
+ ],
1187
+ });
789
1188
  }
1189
+ near.sort((a, b) => b.proximity - a.proximity);
1190
+ for (const item of near) anchors.push(...item.anchors);
790
1191
  const impact = runTrace(graph, {
791
1192
  type: "trace",
792
1193
  from: id,
793
1194
  direction: "impact",
794
1195
  maxDepth: 4,
795
1196
  maxNodes: 16,
796
- });
1197
+ }).result;
797
1198
  for (const node of impact.reached) {
798
1199
  if (node.roles?.includes("test")) {
799
1200
  anchors.push(...anchorFromNode("test coverage", node));