@ttsc/graph 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +3 -1
  2. package/lib/index.d.ts +1 -1
  3. package/lib/index.js +15 -3
  4. package/lib/index.js.map +1 -1
  5. package/lib/model/TtscGraphMemory.d.ts +26 -0
  6. package/lib/model/TtscGraphMemory.js +121 -2
  7. package/lib/model/TtscGraphMemory.js.map +1 -1
  8. package/lib/model/TtscGraphSession.d.ts +23 -0
  9. package/lib/model/TtscGraphSession.js +248 -96
  10. package/lib/model/TtscGraphSession.js.map +1 -1
  11. package/lib/model/TtscGraphShardStore.js +6 -1
  12. package/lib/model/TtscGraphShardStore.js.map +1 -1
  13. package/lib/model/TtscLintDaemon.d.ts +54 -0
  14. package/lib/model/TtscLintDaemon.js +166 -0
  15. package/lib/model/TtscLintDaemon.js.map +1 -0
  16. package/lib/model/loadGraph.d.ts +8 -1
  17. package/lib/model/loadGraph.js +144 -72
  18. package/lib/model/loadGraph.js.map +1 -1
  19. package/lib/model/publishedArtifacts.d.ts +142 -0
  20. package/lib/model/publishedArtifacts.js +403 -0
  21. package/lib/model/publishedArtifacts.js.map +1 -0
  22. package/lib/reduce.js +20 -7
  23. package/lib/reduce.js.map +1 -1
  24. package/lib/server/createServer.js +38 -2
  25. package/lib/server/createServer.js.map +1 -1
  26. package/lib/server/runDetails.d.ts +15 -0
  27. package/lib/server/runDetails.js +48 -0
  28. package/lib/server/runDetails.js.map +1 -1
  29. package/lib/server/runEntrypoints.js +7 -1
  30. package/lib/server/runEntrypoints.js.map +1 -1
  31. package/lib/server/runLookup.js +129 -11
  32. package/lib/server/runLookup.js.map +1 -1
  33. package/lib/structures/ITtscGraphApplication.d.ts +3 -1
  34. package/lib/structures/ITtscGraphDetails.d.ts +12 -0
  35. package/lib/structures/ITtscGraphDocTag.d.ts +44 -0
  36. package/lib/structures/ITtscGraphDocTag.js +3 -0
  37. package/lib/structures/ITtscGraphDocTag.js.map +1 -0
  38. package/lib/structures/ITtscGraphDump.d.ts +23 -4
  39. package/lib/structures/ITtscGraphLookup.d.ts +29 -0
  40. package/lib/structures/ITtscGraphNode.d.ts +25 -0
  41. package/lib/structures/TtscGraphArtifactNodeKind.d.ts +23 -0
  42. package/lib/structures/TtscGraphArtifactNodeKind.js +37 -0
  43. package/lib/structures/TtscGraphArtifactNodeKind.js.map +1 -0
  44. package/lib/structures/TtscGraphDumpEdgeKind.d.ts +1 -1
  45. package/lib/structures/TtscGraphDumpNodeKind.d.ts +10 -2
  46. package/lib/structures/TtscGraphEdgeKind.d.ts +8 -1
  47. package/lib/structures/TtscGraphNodeKind.d.ts +1 -1
  48. package/lib/structures/index.d.ts +2 -0
  49. package/lib/structures/index.js +2 -0
  50. package/lib/structures/index.js.map +1 -1
  51. package/lib/view.js +17 -1
  52. package/lib/view.js.map +1 -1
  53. package/lib/viewer/index.html +5 -13
  54. package/lib/viewer/viewer.js +190 -190
  55. package/package.json +5 -5
  56. package/src/index.ts +23 -6
  57. package/src/model/TtscGraphMemory.ts +118 -2
  58. package/src/model/TtscGraphSession.ts +112 -2
  59. package/src/model/TtscGraphShardStore.ts +6 -1
  60. package/src/model/TtscLintDaemon.ts +179 -0
  61. package/src/model/loadGraph.ts +29 -5
  62. package/src/model/publishedArtifacts.ts +548 -0
  63. package/src/reduce.ts +20 -7
  64. package/src/server/runDetails.ts +52 -1
  65. package/src/server/runEntrypoints.ts +7 -1
  66. package/src/server/runLookup.ts +139 -14
  67. package/src/structures/ITtscGraphApplication.ts +3 -1
  68. package/src/structures/ITtscGraphDetails.ts +13 -0
  69. package/src/structures/ITtscGraphDocTag.ts +45 -0
  70. package/src/structures/ITtscGraphDump.ts +25 -4
  71. package/src/structures/ITtscGraphLookup.ts +31 -0
  72. package/src/structures/ITtscGraphNode.ts +27 -0
  73. package/src/structures/TtscGraphArtifactNodeKind.ts +35 -0
  74. package/src/structures/TtscGraphDumpEdgeKind.ts +1 -0
  75. package/src/structures/TtscGraphDumpNodeKind.ts +16 -2
  76. package/src/structures/TtscGraphEdgeKind.ts +8 -0
  77. package/src/structures/TtscGraphNodeKind.ts +7 -1
  78. package/src/structures/index.ts +2 -0
  79. package/src/view.ts +17 -1
  80. package/src/viewer/index.html +5 -13
  81. package/src/viewer/legend.ts +107 -0
  82. package/src/viewer/main.ts +19 -21
@@ -0,0 +1,548 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { type ITtscCapabilityPlugin, resolveCapabilityPlugins } from "ttsc";
7
+
8
+ import { TtscLintDaemon } from "./TtscLintDaemon";
9
+
10
+ /**
11
+ * Ask the project's `@ttsc/lint` for the artifacts a citation can name, and
12
+ * write them where `ttscgraph dump --artifacts` reads them.
13
+ *
14
+ * A project that configures no such plugin gets `file: null`, which is the
15
+ * common case and not an error: the graph it produces is the graph it produced
16
+ * before this existed, and the dump says so by not claiming the capability.
17
+ *
18
+ * ## Why this runs here and not in the compiler host
19
+ *
20
+ * The addresses a citation names — a Markdown anchor, `prisma:Sale.price`,
21
+ * `POST:/orders` — are produced by parsers that live in the rule that owns
22
+ * them, and re-deriving any of them in the graph producer would be a second
23
+ * implementation of a published contract. So the units have to arrive from the
24
+ * rule.
25
+ *
26
+ * They cannot arrive in-process. `ttscgraph` is the shipped per-platform
27
+ * binary, never a per-project native host, so it can never have a linked
28
+ * plugin; and `packages/lint` is its own Go module that deliberately carries no
29
+ * requirement on the compiler host. What is left is the channel the host
30
+ * already has: a plugin declares a capability and its sidecar answers a verb,
31
+ * exactly as `lsp-hints` does. `resolveCapabilityPlugins` is what builds and
32
+ * locates those sidecars, and it is the seam `ttscserver` already uses for
33
+ * `capabilities.lsp`, published so a consumer outside the compiler can ask
34
+ * too.
35
+ *
36
+ * Nothing here knows what `@ttsc/evidence` is. It asks a lint install for
37
+ * whatever its configured rules published, and a project that configured none
38
+ * gets an empty answer.
39
+ */
40
+ export interface IPublishedArtifacts {
41
+ /**
42
+ * Path to the JSON the native producer reads, or `null` when no configured
43
+ * plugin publishes one.
44
+ *
45
+ * `null` is a state rather than an absence, which is why it still carries
46
+ * {@link inputs}. A project that adds an evidence plugin while a session is
47
+ * running would otherwise never be reconsidered: nothing would be watched, so
48
+ * nothing could report that the answer had changed from "none" to "some".
49
+ */
50
+ file: string | null;
51
+ /**
52
+ * Everything the answer was derived from, as paths this process can state for
53
+ * itself.
54
+ *
55
+ * The artifacts describe documents the compiler's Program never read, so a
56
+ * source edit does not move them and a document edit does not move the code
57
+ * graph. Refreshing them is therefore a second invalidation with its own
58
+ * inputs, and these are those inputs.
59
+ */
60
+ inputs: IArtifactInputs;
61
+ /**
62
+ * The state of {@link inputs} when the answer was produced.
63
+ *
64
+ * Compared against a freshly taken one to decide whether the answer is stale.
65
+ * When it moved, nothing else in the session can tell: the compiler's own
66
+ * invalidation watches the build universe, and none of this is in it.
67
+ */
68
+ fingerprint: string;
69
+ }
70
+
71
+ /** Paths an answer was derived from, split by how they are watched. */
72
+ export interface IArtifactInputs {
73
+ /** Files stated one by one. */
74
+ files: string[];
75
+ /** Directories walked, which is what notices an added or deleted file. */
76
+ directories: IArtifactDirectory[];
77
+ }
78
+
79
+ /** A directory watched on behalf of the pattern that named it. */
80
+ export interface IArtifactDirectory {
81
+ /** Absolute path of the directory to walk. */
82
+ path: string;
83
+ /**
84
+ * Whether the walk descends.
85
+ *
86
+ * Taken from the pattern rather than assumed, because assuming it is
87
+ * expensive in exactly the case that looks harmless: a rule declaring `*.md`
88
+ * has the project root for its fixed prefix, and treating that as recursive
89
+ * would state every file in the repository before every graph request.
90
+ */
91
+ recursive: boolean;
92
+ }
93
+
94
+ export function publishArtifacts(options: {
95
+ cwd: string;
96
+ tsconfig: string;
97
+ }): IPublishedArtifacts {
98
+ const plugins = resolveCapabilityPlugins({
99
+ capability: "graphNodes",
100
+ cwd: options.cwd,
101
+ tsconfig: options.tsconfig,
102
+ });
103
+ if (plugins.length === 0) return unpublished(options);
104
+ // The inputs are stated before the set is asked for, never after. A document
105
+ // edited between the two calls has to read as a change next time, and only
106
+ // this order gives that: a fingerprint taken first describes a state at least
107
+ // as old as the set it labels, so the worst it can cost is one republish that
108
+ // finds nothing new. Taken afterwards it would describe a state newer than
109
+ // the set, and the edit that landed in the gap would read as already
110
+ // accounted for — the exact staleness this exists to remove.
111
+ const inputs = readInputs(
112
+ plugins.map((plugin) => runVerb(plugin, "project-inputs", options)),
113
+ options,
114
+ );
115
+ return assemble(
116
+ options,
117
+ inputs,
118
+ plugins.map((plugin) => runVerb(plugin, "graph-nodes", options)),
119
+ );
120
+ }
121
+
122
+ /**
123
+ * The same answer, asked of sidecars this caller keeps open.
124
+ *
125
+ * A one-shot has nothing to amortize — its process exits after one question —
126
+ * so `publishArtifacts` stays a spawn per verb and stays synchronous, which is
127
+ * what its three CLI callers are. A resident session asks both verbs again
128
+ * every time a document moves, and that is where a process, a plugin load and a
129
+ * Program per question stopped being affordable.
130
+ *
131
+ * A daemon that cannot answer falls back to the direct command for that plugin,
132
+ * so the answer is the same one either way and only its cost differs. The
133
+ * fallback matters more here than most: a daemon that quietly answered nothing
134
+ * would be indistinguishable from a project that publishes nothing.
135
+ */
136
+ export async function publishArtifactsResident(
137
+ options: { cwd: string; tsconfig: string },
138
+ daemon: (plugin: ITtscCapabilityPlugin) => TtscLintDaemon | undefined,
139
+ ): Promise<IPublishedArtifacts> {
140
+ const plugins = resolveCapabilityPlugins({
141
+ capability: "graphNodes",
142
+ cwd: options.cwd,
143
+ tsconfig: options.tsconfig,
144
+ });
145
+ if (plugins.length === 0) return unpublished(options);
146
+ // The first request of a republish drops the daemon's warm Program. The
147
+ // artifacts depend on which sources exist and what they declare — that is
148
+ // what activates a claim — and between two republishes the developer has
149
+ // been editing code as well as documents. Reusing a Program from before
150
+ // those edits would deactivate a claim whose files now exist, which is a
151
+ // stale answer of exactly the kind this whole mechanism removes. What the
152
+ // daemon still saves is the process, the plugin load, and the configuration
153
+ // evaluation, which is most of the cost.
154
+ const inputs = readInputs(
155
+ await Promise.all(
156
+ plugins.map((plugin) =>
157
+ askVerb(plugin, "project-inputs", options, daemon(plugin), true),
158
+ ),
159
+ ),
160
+ options,
161
+ );
162
+ return assemble(
163
+ options,
164
+ inputs,
165
+ await Promise.all(
166
+ plugins.map((plugin) =>
167
+ askVerb(plugin, "graph-nodes", options, daemon(plugin), false),
168
+ ),
169
+ ),
170
+ );
171
+ }
172
+
173
+ /** Run one verb as its own process, returning its stdout or `null`. */
174
+ function runVerb(
175
+ plugin: ITtscCapabilityPlugin,
176
+ verb: string,
177
+ options: { cwd: string; tsconfig: string },
178
+ ): string | null {
179
+ const result = spawnSync(
180
+ plugin.binary,
181
+ [
182
+ verb,
183
+ "--cwd",
184
+ options.cwd,
185
+ "--tsconfig",
186
+ options.tsconfig,
187
+ // The sidecar finds its own configured entry in this manifest. Without
188
+ // it, it loads an empty rule configuration and answers as though the
189
+ // project declared nothing — an empty answer indistinguishable from a
190
+ // project that genuinely publishes none.
191
+ `--plugins-json=${plugin.manifest}`,
192
+ ...projectContextArgs(plugin),
193
+ ],
194
+ {
195
+ // The set is one entry per document section, model field, and operation —
196
+ // bounded by the project's own documentation, not by its source — so the
197
+ // default pipe ceiling is raised rather than removed.
198
+ maxBuffer: 256 * 1024 * 1024,
199
+ encoding: "utf8",
200
+ windowsHide: true,
201
+ },
202
+ );
203
+ // A plugin that cannot answer is not a broken graph. The verb is new, so a
204
+ // plugin built from an older source rejects the command outright, and a
205
+ // project whose config does not parse has already failed somewhere the user
206
+ // can see. Either way the graph is the one that existed before this, and the
207
+ // absent capability claim is what says the producer got no answer.
208
+ if (result.error || result.status !== 0 || typeof result.stdout !== "string")
209
+ return null;
210
+ return result.stdout;
211
+ }
212
+
213
+ /** Ask one verb through a daemon, or as a process when it cannot answer. */
214
+ async function askVerb(
215
+ plugin: ITtscCapabilityPlugin,
216
+ verb: string,
217
+ options: { cwd: string; tsconfig: string },
218
+ daemon: TtscLintDaemon | undefined,
219
+ invalidate: boolean,
220
+ ): Promise<string | null> {
221
+ const served = await daemon?.ask(verb, invalidate);
222
+ return served ?? runVerb(plugin, verb, options);
223
+ }
224
+
225
+ /** The artifact answer, from each sidecar's `graph-nodes` output. */
226
+ function assemble(
227
+ options: { cwd: string; tsconfig: string },
228
+ inputs: IArtifactInputs,
229
+ outputs: readonly (string | null)[],
230
+ ): IPublishedArtifacts {
231
+ const fingerprint = fingerprintInputs(inputs);
232
+ const published: unknown[] = [];
233
+ for (const output of outputs) {
234
+ if (output === null) continue;
235
+ try {
236
+ const parsed: unknown = JSON.parse(output);
237
+ if (Array.isArray(parsed)) published.push(...parsed);
238
+ } catch {
239
+ continue;
240
+ }
241
+ }
242
+ if (published.length === 0) return { file: null, fingerprint, inputs };
243
+
244
+ // One file per process and project, overwritten, rather than a fresh temp
245
+ // directory per call. A directory per call is a leak nothing here is
246
+ // positioned to clean — the path outlives this function by design, since the
247
+ // native producer reads it after we return — and `loadGraph` is a library
248
+ // entry a caller may run in a loop.
249
+ //
250
+ // Per project as well as per process, because `TtscGraphSession` is exported
251
+ // and a consumer holding one per workspace is an ordinary thing to do. Keyed
252
+ // on the process alone, the second session's set would overwrite the first's
253
+ // between the moment it was written and the moment the first session's child
254
+ // reads the path it was handed — and a graph answering with another project's
255
+ // artifacts is exactly the silently-wrong answer this channel exists to make
256
+ // impossible.
257
+ const file = path.join(
258
+ os.tmpdir(),
259
+ `ttsc-graph-artifacts-${String(process.pid)}-${projectKey(options)}.json`,
260
+ );
261
+ fs.writeFileSync(file, JSON.stringify(published));
262
+ return { file, fingerprint, inputs };
263
+ }
264
+
265
+ /**
266
+ * Whether the inputs an answer was derived from have moved since.
267
+ *
268
+ * Answered by stating paths this process already knows, not by asking the
269
+ * plugin again. The question is asked before every graph request in a resident
270
+ * session, and re-running plugin discovery per request would cost more than the
271
+ * refresh it guards — while a `stat` per document costs less than reading one
272
+ * of them.
273
+ *
274
+ * The cost of being wrong in the cheap direction is what makes this worth
275
+ * paying at all: a developer who edited only a spec section, and nothing the
276
+ * compiler reads, otherwise saw the graph keep answering with the headings that
277
+ * section used to have.
278
+ */
279
+ export function artifactsAreStale(published: IPublishedArtifacts): boolean {
280
+ // The written set is one of its own inputs, by existence alone. It lives in
281
+ // the system temp directory, which is swept on a schedule this session has no
282
+ // say in, and the server is handed the path on every request — so once it is
283
+ // gone every later request fails as a broken exchange, and the only cure is
284
+ // restarting the editor. That is the outcome `unpublished` exists to avoid,
285
+ // and it would be odd to accept it here.
286
+ //
287
+ // Existence and nothing else. The file is written after this answer's
288
+ // fingerprint was taken, so folding its size or time into that state would
289
+ // report stale forever.
290
+ if (published.file !== null && !fs.existsSync(published.file)) return true;
291
+ return fingerprintInputs(published.inputs) !== published.fingerprint;
292
+ }
293
+
294
+ /**
295
+ * The answer for a project that publishes nothing, and what to watch so that
296
+ * answer can change.
297
+ *
298
+ * Configuring a plugin means editing the project's tsconfig or installing a
299
+ * package that declares one, so those two files are what could turn this answer
300
+ * into a different one. They are stated rather than the whole discovery being
301
+ * re-run, because re-running it walks the dependency closure — the cost
302
+ * samchon/ttsc#1276 is about — and paying that per request to learn nothing
303
+ * would be worse than the staleness it prevents.
304
+ *
305
+ * Bounded, and deliberately: a tsconfig that inherits its plugins from an
306
+ * extended config is not tracked here, because naming the whole extends chain
307
+ * means asking the loader that is itself the expense.
308
+ */
309
+ function unpublished(options: {
310
+ cwd: string;
311
+ tsconfig: string;
312
+ }): IPublishedArtifacts {
313
+ const inputs: IArtifactInputs = {
314
+ directories: [],
315
+ files: [
316
+ path.resolve(options.cwd, options.tsconfig),
317
+ path.resolve(options.cwd, "package.json"),
318
+ ],
319
+ };
320
+ return { file: null, fingerprint: fingerprintInputs(inputs), inputs };
321
+ }
322
+
323
+ /**
324
+ * What each sidecar said its rules read, as one watch list.
325
+ *
326
+ * The `project-inputs` verb exists for exactly this question: `@ttsc/lint`
327
+ * publishes it so a host can learn that a rule depends on files the Program
328
+ * never loads. Its snapshot carries both halves of what is needed here — the
329
+ * plugin's own configuration files, and the globs the rules declared — so a
330
+ * configuration edit and a document edit are noticed by the same state rather
331
+ * than by two mechanisms that could disagree.
332
+ */
333
+ function readInputs(
334
+ outputs: readonly (string | null)[],
335
+ options: { cwd: string; tsconfig: string },
336
+ ): IArtifactInputs {
337
+ const files: string[] = [];
338
+ const directories: IArtifactDirectory[] = [];
339
+ for (const output of outputs) {
340
+ if (output === null) continue;
341
+ let snapshot: {
342
+ root?: string;
343
+ files?: string[];
344
+ globs?: string[];
345
+ reloadFiles?: string[];
346
+ reloadDirectories?: string[];
347
+ };
348
+ try {
349
+ snapshot = JSON.parse(output) as typeof snapshot;
350
+ } catch {
351
+ continue;
352
+ }
353
+ // The snapshot names the base its own paths are relative to. It normalizes
354
+ // them to absolute today, so this changes nothing now and is what keeps a
355
+ // relative answer from being resolved against the wrong directory later —
356
+ // silently, since a path that does not exist states itself absent and reads
357
+ // as a project whose documents were all deleted.
358
+ const base = snapshot.root ?? options.cwd;
359
+ for (const file of [
360
+ ...(snapshot.files ?? []),
361
+ ...(snapshot.reloadFiles ?? []),
362
+ ])
363
+ files.push(absolute(file, base));
364
+ for (const pattern of snapshot.globs ?? []) {
365
+ const directory = watchedBy(pattern, base);
366
+ if (directory === null) files.push(absolute(pattern, base));
367
+ else directories.push(directory);
368
+ }
369
+ // A reload directory is a resolution anchor, not a content tree.
370
+ // `@ttsc/lint` publishes the directories whose *immediate* topology decides
371
+ // which rules load — a `node_modules` chain, a config directory — and the
372
+ // LSP host watches exactly `<dir>/*` for that reason. Walking them
373
+ // recursively both over-invalidates, restarting on any descendant edit, and
374
+ // states the whole dependency tree before every graph request.
375
+ for (const directory of snapshot.reloadDirectories ?? [])
376
+ directories.push({ path: absolute(directory, base), recursive: false });
377
+ }
378
+ // A directory named twice is walked once, and a recursive claim wins: two
379
+ // patterns over one tree, one descending and one not, must not leave the
380
+ // descending one's files unwatched because the other was seen first.
381
+ const merged = new Map<string, boolean>();
382
+ for (const directory of directories)
383
+ merged.set(
384
+ directory.path,
385
+ (merged.get(directory.path) ?? false) || directory.recursive,
386
+ );
387
+ return {
388
+ directories: [...merged]
389
+ .map(([directory, recursive]) => ({ path: directory, recursive }))
390
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
391
+ files: [...new Set(files)].sort(),
392
+ };
393
+ }
394
+
395
+ /**
396
+ * The directory a declared pattern makes worth walking, or `null` when the
397
+ * pattern names one path and should simply be stated.
398
+ *
399
+ * Two readings have to be right here, and both are cheap to get wrong. A
400
+ * pattern carrying no wildcard is a path, not a tree: watching its parent
401
+ * instead would state every sibling on every request to learn about the one
402
+ * file that was declared. And a pattern that does not say `**` does not descend
403
+ * — treating it as though it did is unbounded in exactly the case that looks
404
+ * harmless, because a bare `*.md` has the project root for its prefix.
405
+ */
406
+ export function watchedBy(
407
+ pattern: string,
408
+ cwd: string,
409
+ ): IArtifactDirectory | null {
410
+ if (pattern.search(GLOB_MAGIC) < 0) return null;
411
+ return { path: globRoot(pattern, cwd), recursive: pattern.includes("**") };
412
+ }
413
+
414
+ /**
415
+ * The project identity flag, for a plugin whose descriptor asks for one.
416
+ *
417
+ * A rule resolves its own inputs — the documents a claim reads, a schema, an
418
+ * OpenAPI file — against the project root, and a sidecar is handed that root
419
+ * rather than deriving it. Without the flag the rule has no base, and it
420
+ * answers with an empty set rather than an error, because "this project
421
+ * declares nothing" is a legitimate answer it cannot tell apart from "nobody
422
+ * told me where the project is". That is why every verb here passes it and why
423
+ * the case covering this drives a real project: an empty answer is exactly what
424
+ * a synthetic fixture would also have produced.
425
+ */
426
+ function projectContextArgs(plugin: {
427
+ projectContext?: string;
428
+ }): readonly string[] {
429
+ return plugin.projectContext === undefined
430
+ ? []
431
+ : [`--project-context-json=${plugin.projectContext}`];
432
+ }
433
+
434
+ /**
435
+ * The project a published set belongs to, as a filename-safe tag.
436
+ *
437
+ * Short rather than the whole digest: it distinguishes the projects one process
438
+ * drives, which is a handful, and the process id beside it already separates
439
+ * two runs.
440
+ */
441
+ function projectKey(options: { cwd: string; tsconfig: string }): string {
442
+ return createHash("sha256")
443
+ .update(path.resolve(options.cwd))
444
+ .update(SEPARATOR)
445
+ .update(options.tsconfig)
446
+ .digest("hex")
447
+ .slice(0, 16);
448
+ }
449
+
450
+ /** Wildcards a pattern may use; a pattern with none of them names one path. */
451
+ const GLOB_MAGIC = /[*?[{]/u;
452
+
453
+ /** A declared path resolved against the project root. */
454
+ function absolute(target: string, cwd: string): string {
455
+ return path.isAbsolute(target) ? target : path.join(cwd, target);
456
+ }
457
+
458
+ /**
459
+ * The state of every input, as one comparable string.
460
+ *
461
+ * Files are stated by size and modification time rather than content: this runs
462
+ * before every graph request, and hashing a documentation corpus per request
463
+ * would cost more than the refresh it guards. Directories are walked for the
464
+ * same pair, which is what notices a section added or a document deleted rather
465
+ * than edited.
466
+ */
467
+ export function fingerprintInputs(inputs: IArtifactInputs): string {
468
+ const parts: string[] = [];
469
+ for (const file of inputs.files) parts.push(stateOf(file));
470
+ for (const directory of inputs.directories)
471
+ parts.push(...walkState(directory.path, directory.recursive));
472
+ parts.sort();
473
+ return createHash("sha256").update(parts.join("\n")).digest("hex");
474
+ }
475
+
476
+ /**
477
+ * A path's size and modification time, or a marker when it is absent.
478
+ *
479
+ * The fields are joined on a character a path cannot contain. Separated by a
480
+ * space, a file literally named `a 1 2` states the same string as a one-byte
481
+ * file named `a`, and an edit to either would then read as no edit at all.
482
+ */
483
+ function stateOf(file: string): string {
484
+ try {
485
+ const stat = fs.statSync(file);
486
+ return [file, String(stat.size), String(stat.mtimeMs)].join(SEPARATOR);
487
+ } catch {
488
+ return [file, "absent"].join(SEPARATOR);
489
+ }
490
+ }
491
+
492
+ /**
493
+ * The character that joins fields a path could otherwise forge.
494
+ *
495
+ * Built rather than written literally: a source file carrying a raw NUL is one
496
+ * Git classifies as binary, which exempts it from this repository's end-of-line
497
+ * contract and leaves it with no textual diff for a reviewer.
498
+ */
499
+ const SEPARATOR = String.fromCharCode(0);
500
+
501
+ /**
502
+ * The fixed directory prefix of a glob, which is what there is to walk.
503
+ *
504
+ * A pattern with no directory part at all names the project root, which is the
505
+ * one reading that keeps a bare `*.md` from being walked as though it were a
506
+ * directory named `*.md`.
507
+ */
508
+ function globRoot(pattern: string, cwd: string): string {
509
+ const magic = pattern.search(GLOB_MAGIC);
510
+ const head = magic < 0 ? pattern : pattern.slice(0, magic);
511
+ const slash = Math.max(head.lastIndexOf("/"), head.lastIndexOf("\\"));
512
+ const root = slash < 0 ? "" : head.slice(0, slash);
513
+ return root === "" ? cwd : absolute(root, cwd);
514
+ }
515
+
516
+ /**
517
+ * Every entry below `directory`, stated.
518
+ *
519
+ * Bounded three ways, because this runs before every graph request: it descends
520
+ * only when the pattern that named the directory descends, it never enters
521
+ * `node_modules`, and it stops at a depth no documentation tree reaches. Dotted
522
+ * directories are skipped below the declared root, which is what the glob that
523
+ * named it would have matched anyway.
524
+ *
525
+ * A directory that does not exist states itself absent, which is what notices
526
+ * one being created.
527
+ */
528
+ function walkState(directory: string, recursive: boolean, depth = 0): string[] {
529
+ if (depth > 12) return [];
530
+ let entries: fs.Dirent[];
531
+ try {
532
+ entries = fs.readdirSync(directory, { withFileTypes: true });
533
+ } catch {
534
+ return [stateOf(directory)];
535
+ }
536
+ const states: string[] = [];
537
+ for (const entry of entries) {
538
+ const child = path.join(directory, entry.name);
539
+ if (entry.isDirectory()) {
540
+ if (!recursive) continue;
541
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
542
+ states.push(...walkState(child, recursive, depth + 1));
543
+ continue;
544
+ }
545
+ states.push(stateOf(child));
546
+ }
547
+ return states;
548
+ }
package/src/reduce.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  // Reduce a raw `ttscgraph dump` to the payload the bundled 3D viewer renders.
2
- // This mirrors website/src/components/graph/graphReduce.ts (the same pure
3
- // transform); keep the two in sync. The CLI reduces in Node before serving, so
4
- // the browser viewer only ever renders a ready `{ nodes, links }`.
2
+ // The same pure transform is copied in
3
+ // website/src/components/graph/TtscWebsiteGraphReduce.ts and
4
+ // benchmarks/graph/src/TtscBenchmarkGraphReduce.ts, because neither consumer
5
+ // depends on this package and adding one would be a new build dependency for a
6
+ // browser bundle and a dependency-free benchmark. The copies are held together
7
+ // by tests/test-graph rather than by this comment. The CLI reduces in Node
8
+ // before serving, so the browser viewer only ever renders a ready
9
+ // `{ nodes, links }`.
5
10
 
6
11
  export interface RawNode {
7
12
  id: string;
@@ -203,10 +208,16 @@ function degreeOf(
203
208
  }
204
209
 
205
210
  /**
206
- * Collapse the fine-grained wire kinds `ttscgraph dump` emits (calls,
207
- * instantiates, renders, accesses, type_ref, extends, implements, overrides)
208
- * into the three display families the viewer colors and its legend name. An
209
- * unknown kind passes through and renders with the fallback color.
211
+ * Collapse every wire kind `ttscgraph dump` emits into the display families the
212
+ * viewer colors and its legend names. An unknown kind passes through and
213
+ * renders with the fallback color.
214
+ *
215
+ * The map has to be total over what a dump can carry. `exports` was missing and
216
+ * therefore drawn in the fallback color under no legend entry and, on the
217
+ * website, under no filter row — visible, unnamed, and unfilterable. The two
218
+ * kinds `TtscGraphEdgeKind` declares beyond this map cannot reach a dump:
219
+ * `contains` is synthesized by the TypeScript memory layer and `dispatches` is
220
+ * trace-only.
210
221
  */
211
222
  const DISPLAY_KIND: Record<string, string> = {
212
223
  calls: "value-call",
@@ -214,9 +225,11 @@ const DISPLAY_KIND: Record<string, string> = {
214
225
  renders: "value-call",
215
226
  accesses: "value-call",
216
227
  type_ref: "type-ref",
228
+ doc_ref: "doc-ref",
217
229
  extends: "heritage",
218
230
  implements: "heritage",
219
231
  overrides: "heritage",
232
+ exports: "exports",
220
233
  };
221
234
 
222
235
  function displayKind(kind: string): string {
@@ -1,6 +1,7 @@
1
- import { TtscGraphMemory } from "../model/TtscGraphMemory";
1
+ import { TtscGraphMemory, leadingToken } from "../model/TtscGraphMemory";
2
2
  import { ITtscGraphDecorator } from "../structures/ITtscGraphDecorator";
3
3
  import { ITtscGraphDetails } from "../structures/ITtscGraphDetails";
4
+ import { ITtscGraphDocTag } from "../structures/ITtscGraphDocTag";
4
5
  import { ITtscGraphEdge } from "../structures/ITtscGraphEdge";
5
6
  import { ITtscGraphEvidence } from "../structures/ITtscGraphEvidence";
6
7
  import { ITtscGraphNode } from "../structures/ITtscGraphNode";
@@ -106,6 +107,8 @@ export function runDetails(
106
107
  if (doc !== undefined) detail.doc = doc;
107
108
  const decorators = decoratorsOf(node);
108
109
  if (decorators !== undefined) detail.decorators = decorators;
110
+ const docTags = docTagsOf(node);
111
+ if (docTags !== undefined) detail.docTags = docTags;
109
112
  const implementation = evidenceCoordinatesOf(node.implementation);
110
113
  if (implementation !== undefined) detail.implementation = implementation;
111
114
  const span = implementation ?? evidenceCoordinatesOf(node.evidence);
@@ -483,6 +486,54 @@ export function decoratorsOf(
483
486
  : undefined;
484
487
  }
485
488
 
489
+ /**
490
+ * The declaration's documentation tags, with each text elided at the same
491
+ * length a doc summary is, optionally narrowed to the ones a caller matched.
492
+ *
493
+ * The text is a reason written for a human reader, so the same budget applies:
494
+ * enough to judge what the declaration claims, not the whole paragraph. What
495
+ * the budget may never reach is the address the text opens with, which is a
496
+ * hit's explanation of why it is in the result; {@link elideTagText} keeps it.
497
+ *
498
+ * The optional filter narrows to the tags a caller matched. It runs on the
499
+ * node's own tags, before any elision, so a tag found by an address longer than
500
+ * the budget is still recognized as the one that matched.
501
+ */
502
+ export function docTagsOf(
503
+ node: ITtscGraphNode,
504
+ keep?: (tag: ITtscGraphDocTag) => boolean,
505
+ ): ITtscGraphDocTag[] | undefined {
506
+ if (node.docTags === undefined || node.docTags.length === 0) return undefined;
507
+ const selected =
508
+ keep === undefined ? node.docTags : node.docTags.filter(keep);
509
+ if (selected.length === 0) return undefined;
510
+ return selected.map((tag) =>
511
+ tag.text !== undefined && tag.text.length > MAX_DOC_CHARS
512
+ ? { ...tag, text: elideTagText(tag.text) }
513
+ : tag,
514
+ );
515
+ }
516
+
517
+ /**
518
+ * Cut a tag text to the doc budget without cutting the address it opens with.
519
+ *
520
+ * The protected prefix is whatever the index calls the address, taken from the
521
+ * same function the reverse lookup keys on, so the two cannot disagree about
522
+ * where it ends: a braced link holds spaces inside its group, and measuring the
523
+ * prefix to the first whitespace instead would return `{@link` and cut the rest
524
+ * of the group away.
525
+ *
526
+ * A text that is nothing but an over-long address comes back whole, with no
527
+ * marker — an ellipsis on text that was never cut says something false about
528
+ * the one field a caller reads to know whether to go on reading.
529
+ */
530
+ function elideTagText(text: string): string {
531
+ const address = leadingToken(text)?.length ?? 0;
532
+ const keep = Math.max(MAX_DOC_CHARS, address);
533
+ if (keep >= text.length) return text;
534
+ return text.slice(0, keep).trimEnd() + "…";
535
+ }
536
+
486
537
  /** Relationship evidence as public coordinates, omitted when absent. */
487
538
  export function edgeEvidenceOf(
488
539
  edge: ITtscGraphEdge,
@@ -38,7 +38,13 @@ export function runEntrypoints(
38
38
  query,
39
39
  limit,
40
40
  }).result;
41
- const hits = lookupResult.hits.map((hit) => ({ ...hit }));
41
+ // Drop what this result's own shape does not declare. `lookup` may carry the
42
+ // documentation tags that matched a citation query; entrypoints answers a
43
+ // natural-language "where does this start" and has no such field, so copying
44
+ // the hit wholesale would put an undeclared field on the wire.
45
+ const hits = lookupResult.hits.map(({ docTags: _docTags, ...hit }) => ({
46
+ ...hit,
47
+ }));
42
48
 
43
49
  const mentions = directMentions(graph, query).map((handle) => {
44
50
  const resolved = resolveGraphHandle(graph, handle, 6);