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