@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/graph",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Checker-resolved architecture graph over MCP for coding agents, backed by ttsc's in-process TypeScript-Go compiler.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -16,11 +16,11 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@modelcontextprotocol/sdk": "^1.26.0",
19
- "@typia/mcp": "13.2.0",
20
- "typia": "13.2.0"
19
+ "@typia/mcp": "14.0.0",
20
+ "typia": "14.0.0"
21
21
  },
22
22
  "peerDependencies": {
23
- "ttsc": "^0.27.0"
23
+ "ttsc": "^0.28.0"
24
24
  },
25
25
  "keywords": [
26
26
  "ttsc",
@@ -42,7 +42,7 @@
42
42
  "three": "^0.184.0",
43
43
  "three-forcegraph": "^1.43.4",
44
44
  "typescript": "^7.0.2",
45
- "ttsc": "0.27.0"
45
+ "ttsc": "0.28.0"
46
46
  },
47
47
  "repository": {
48
48
  "type": "git",
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  parseLauncherOptions,
6
6
  projectOptions,
7
7
  } from "./launcherArgs";
8
+ import { publishArtifacts } from "./model/publishedArtifacts";
8
9
  import { ensureExecutable } from "./nativeExecutable";
9
10
  import { resolveGraphBinary } from "./resolveGraphBinary";
10
11
  import { startServer } from "./server/startServer";
@@ -35,7 +36,7 @@ export { resolveGraphBinary } from "./resolveGraphBinary";
35
36
  */
36
37
  export { reduce } from "./reduce";
37
38
  export type { RawDump, RawEdge, RawNode, ViewerPayload } from "./reduce";
38
- export { loadGraph } from "./model/loadGraph";
39
+ export { DUMP_SCHEMA_VERSION, loadGraph } from "./model/loadGraph";
39
40
  export {
40
41
  TtscGraphSession,
41
42
  type TtscGraphRequestOptions,
@@ -121,7 +122,9 @@ function printDumpHelp(): void {
121
122
  function runDump(argv: readonly string[]): number {
122
123
  // Resolve the native binary from the target project the caller named with
123
124
  // `--cwd`, not from wherever the launcher process happened to start.
124
- const { cwd } = projectOptions(parseLauncherOptions(argv, DUMP_OPTIONS));
125
+ const { cwd, tsconfig } = projectOptions(
126
+ parseLauncherOptions(argv, DUMP_OPTIONS),
127
+ );
125
128
  const binary = resolveGraphBinary(process.env, cwd);
126
129
  if (binary === null) {
127
130
  // `ttscgraph` owns the flag contract, so a resolvable binary always answers
@@ -141,10 +144,24 @@ function runDump(argv: readonly string[]): number {
141
144
  return 1;
142
145
  }
143
146
  ensureExecutable(binary);
144
- const result = spawnSync(binary, ["dump", ...argv], {
145
- stdio: "inherit",
146
- windowsHide: true,
147
- });
147
+ // The same artifacts `loadGraph` and the resident session ask for, so the
148
+ // three ways to reach a dump answer a citation the same way. A caller that
149
+ // named `--artifacts` itself owns the answer and is not overridden.
150
+ const published = argv.includes("--artifacts")
151
+ ? null
152
+ : publishArtifacts({ cwd, tsconfig });
153
+ const result = spawnSync(
154
+ binary,
155
+ [
156
+ "dump",
157
+ ...argv,
158
+ ...(published?.file == null ? [] : ["--artifacts", published.file]),
159
+ ],
160
+ {
161
+ stdio: "inherit",
162
+ windowsHide: true,
163
+ },
164
+ );
148
165
  if (result.error) {
149
166
  process.stderr.write(`@ttsc/graph: ${result.error.message}\n`);
150
167
  return 1;
@@ -3,6 +3,7 @@ import { ITtscGraphEdge } from "../structures/ITtscGraphEdge";
3
3
  import { ITtscGraphEvidence } from "../structures/ITtscGraphEvidence";
4
4
  import { ITtscGraphNode } from "../structures/ITtscGraphNode";
5
5
  import { ITtscGraphSpan } from "../structures/ITtscGraphSpan";
6
+ import { isArtifactNodeKind } from "../structures/TtscGraphArtifactNodeKind";
6
7
  import { TtscGraphEdgeKind } from "../structures/TtscGraphEdgeKind";
7
8
  import { ttscGraphNodeIdPath } from "./TtscGraphNodeId";
8
9
  import { TtscGraphSourceReader } from "./TtscGraphSourceReader";
@@ -24,6 +25,7 @@ export class TtscGraphMemory {
24
25
  private readonly inEdges: Map<string, ITtscGraphEdge[]>;
25
26
  private readonly byNameIndex: Map<string, ITtscGraphNode[]>;
26
27
  private readonly bySymbolIndex: Map<string, ITtscGraphNode[]>;
28
+ private readonly byDocTagTarget: Map<string, ITtscGraphNode[]>;
27
29
 
28
30
  /** The absolute project root the dump was built for. */
29
31
  readonly project: string;
@@ -48,6 +50,7 @@ export class TtscGraphMemory {
48
50
  this.byId = new Map(nodes.map((n) => [n.id, n]));
49
51
  this.byNameIndex = new Map();
50
52
  this.bySymbolIndex = new Map();
53
+ this.byDocTagTarget = new Map();
51
54
  for (const node of nodes) {
52
55
  const bucket = this.byNameIndex.get(node.name);
53
56
  if (bucket) bucket.push(node);
@@ -58,6 +61,16 @@ export class TtscGraphMemory {
58
61
  push(this.bySymbolIndex, node.qualifiedName, node);
59
62
  }
60
63
  }
64
+ for (const target of docTagTargetsOf(node)) {
65
+ const carriers = this.byDocTagTarget.get(target);
66
+ // The membership check is redundant today — `docTagTargetsOf`
67
+ // deduplicates within a node and this loop visits each node once — and
68
+ // it is kept because the cost is a scan of a list that holds the
69
+ // carriers of one address, while the failure it prevents is a
70
+ // declaration reported twice as implementing one specification.
71
+ if (carriers === undefined) this.byDocTagTarget.set(target, [node]);
72
+ else if (!carriers.includes(node)) carriers.push(node);
73
+ }
61
74
  }
62
75
  this.outEdges = new Map();
63
76
  this.inEdges = new Map();
@@ -102,6 +115,100 @@ export class TtscGraphMemory {
102
115
  exported(): ITtscGraphNode[] {
103
116
  return this.nodes.filter((n) => n.exported && !n.external);
104
117
  }
118
+
119
+ /**
120
+ * Every declaration whose documentation names this address — the reverse of
121
+ * the citation question.
122
+ *
123
+ * The forward direction costs a reader one file: the tag sits above the
124
+ * declaration they already found. The reverse direction is what an index is
125
+ * for, because the declarations implementing one specification are scattered
126
+ * across every file that implements it, and finding them otherwise means
127
+ * searching the whole repository.
128
+ *
129
+ * The address is exact and is spelled as {@link documentationTarget} decides,
130
+ * so a caller passes that function's output rather than a raw query. Which
131
+ * part of a tag's text names a thing belongs to whatever convention wrote the
132
+ * tag, so this is a selection rule of the consuming layer rather than a fact
133
+ * the producer claims.
134
+ */
135
+ citing(target: string): readonly ITtscGraphNode[] {
136
+ return this.byDocTagTarget.get(target) ?? [];
137
+ }
138
+ }
139
+
140
+ /**
141
+ * The address each of a node's documentation tags names, deduplicated.
142
+ *
143
+ * A braced inline link is one token including its braces (`{@link ISale}`),
144
+ * because that is how the author wrote the target and how a reader searching
145
+ * for it will spell it. Splitting on whitespace alone would key it under
146
+ * `{@link`, which is every link in the project.
147
+ */
148
+ function docTagTargetsOf(node: ITtscGraphNode): string[] {
149
+ if (node.docTags === undefined) return [];
150
+ const targets: string[] = [];
151
+ for (const tag of node.docTags) {
152
+ const token = documentationTarget(tag.text);
153
+ if (token !== undefined && !targets.includes(token)) targets.push(token);
154
+ }
155
+ return targets;
156
+ }
157
+
158
+ /**
159
+ * The address a documentation tag's text opens with, or undefined when its
160
+ * first token is ordinary prose.
161
+ *
162
+ * Every unrecognized tag arrives here, and most of them are not citations:
163
+ * TypeScript has no AST kind for `@remarks`, `@example`, `@todo`, `@internal`,
164
+ * or `@default` either, so their first word reaches this function exactly as a
165
+ * citation target does. Indexing those turned `@todo Add caching here` into a
166
+ * carrier of the address `Add`, and a query opening with that word answered
167
+ * with it — above every real name match, and labelled a certain citation.
168
+ *
169
+ * So a token qualifies only when it carries a separator that prose does not: a
170
+ * path or anchor (`docs/pricing.md#sale`), a namespaced or method-prefixed
171
+ * address (`POST:/orders`, `prisma:Sale`), an inline link, or a URL. An English
172
+ * word carries none of these, and neither does a bare number, so `@default 4`
173
+ * and `@todo Add caching` index nothing while every address form in use does.
174
+ *
175
+ * This is a selection rule, not a claim about the source. It lives here rather
176
+ * than in the producer for that reason: the graph reports the tag as written
177
+ * and this decides only what the ranked operations will match on, which is the
178
+ * layer whose audit already declares its selection heuristic. A convention
179
+ * whose addresses look like prose is simply not indexed; nothing is lost from
180
+ * the tag itself, which `details` still returns in full.
181
+ */
182
+ export function documentationTarget(
183
+ text: string | undefined,
184
+ ): string | undefined {
185
+ const token = leadingToken(text);
186
+ if (token === undefined) return undefined;
187
+ if (token.startsWith("{")) return token;
188
+ // A separator anywhere but the last position: a trailing one is sentence
189
+ // punctuation ("Uses the cache." ends in a dot and names nothing), while an
190
+ // interior one is what every address form spells.
191
+ return /[/#:][^\s]/u.test(token) || /\.[^\s.]/u.test(token)
192
+ ? token
193
+ : undefined;
194
+ }
195
+
196
+ /**
197
+ * The first whitespace-delimited token, or the whole brace group it opens.
198
+ *
199
+ * An unclosed brace group is not a token: `{@link ISale` with the brace
200
+ * forgotten would otherwise fall through to the whitespace split and index the
201
+ * address `{@link`, which every link in the project shares.
202
+ */
203
+ export function leadingToken(text: string | undefined): string | undefined {
204
+ const trimmed = text?.trim();
205
+ if (trimmed === undefined || trimmed === "") return undefined;
206
+ if (trimmed.startsWith("{")) {
207
+ const close = trimmed.indexOf("}");
208
+ return close > 0 ? trimmed.slice(0, close + 1) : undefined;
209
+ }
210
+ const stop = trimmed.search(/\s/u);
211
+ return stop < 0 ? trimmed : trimmed.slice(0, stop);
105
212
  }
106
213
 
107
214
  /** Append value to the slice stored at key, creating the slice on first use. */
@@ -136,7 +243,7 @@ function ownerKey(node: ITtscGraphNode): string | undefined {
136
243
  return owner === "" ? undefined : owner;
137
244
  }
138
245
 
139
- /** A file's id and node name from its schema-v6 path coordinate. */
246
+ /** A file's id and node name from its dump path coordinate. */
140
247
  function fileNodeId(file: string): string {
141
248
  return file;
142
249
  }
@@ -255,7 +362,7 @@ function synthesize(dump: ITtscGraphDump): {
255
362
  });
256
363
  };
257
364
  for (const node of nodes) {
258
- if (node.external) continue;
365
+ if (node.external || isArtifactNodeKind(node.kind)) continue;
259
366
  addFileNode(node.file);
260
367
  }
261
368
  for (const file of moduleFiles) addFileNode(file);
@@ -269,6 +376,15 @@ function synthesize(dump: ITtscGraphDump): {
269
376
  // legacy subpath.
270
377
  const structural: ITtscGraphEdge[] = [];
271
378
  for (const node of nodes) {
379
+ // An artifact is contained by the artifact its producer named, and by
380
+ // nothing when that producer named none. It is never contained by a `file`
381
+ // node: a document is already its own node, a Prisma address carries no
382
+ // path on purpose, and an API operation has no file at all.
383
+ if (isArtifactNodeKind(node.kind)) {
384
+ if (node.parent !== undefined && node.parent !== "")
385
+ structural.push({ from: node.parent, to: node.id, kind: "contains" });
386
+ continue;
387
+ }
272
388
  if (node.external || node.file === "") continue;
273
389
  const parent = owner(node);
274
390
  const container = parent ? parent.id : fileNodeId(node.file);
@@ -7,7 +7,14 @@ import { resolveGraphBinary } from "../resolveGraphBinary";
7
7
  import { ITtscGraphSnapshot } from "../structures/ITtscGraphSnapshot";
8
8
  import { TtscGraphMemory } from "./TtscGraphMemory";
9
9
  import { TtscGraphShardStore } from "./TtscGraphShardStore";
10
+ import { TtscLintDaemon } from "./TtscLintDaemon";
10
11
  import { DUMP_SCHEMA_VERSION } from "./loadGraph";
12
+ import {
13
+ type IPublishedArtifacts,
14
+ artifactsAreStale,
15
+ publishArtifacts,
16
+ publishArtifactsResident,
17
+ } from "./publishedArtifacts";
11
18
 
12
19
  /**
13
20
  * The serve protocol version this client speaks.
@@ -69,6 +76,26 @@ export class TtscGraphSession {
69
76
  private queue: Promise<void> = Promise.resolve();
70
77
  private current: TtscGraphMemory | undefined;
71
78
  private shardStore = new TtscGraphShardStore();
79
+ /**
80
+ * The artifact answer the resident child was last handed, and the state of
81
+ * the inputs it came from.
82
+ *
83
+ * `undefined` only before a child exists. Once one does this is always an
84
+ * answer, including the answer that the project publishes nothing — which
85
+ * still carries inputs, so that adding a publisher is something a running
86
+ * session can notice.
87
+ */
88
+ private artifacts: IPublishedArtifacts | undefined;
89
+ /**
90
+ * One resident `@ttsc/lint` sidecar per plugin binary, opened lazily.
91
+ *
92
+ * A republish asks two verbs of every configured publisher, and a session
93
+ * republishes whenever a document moves. Held open, those questions cost a
94
+ * request each instead of a process, a plugin load and a configuration
95
+ * evaluation each. Keyed by binary because that is what a daemon is: the same
96
+ * binary answering for the same project.
97
+ */
98
+ private readonly daemons = new Map<string, TtscLintDaemon>();
72
99
  private closed = false;
73
100
 
74
101
  public constructor(options: TtscGraphSessionOptions) {
@@ -140,6 +167,12 @@ export class TtscGraphSession {
140
167
  public close(): void {
141
168
  if (this.closed) return;
142
169
  this.closed = true;
170
+ // The sidecars outlive nothing. A daemon is a child process this session
171
+ // opened, and a session that closed without stopping them would leave one
172
+ // resident Program per configured publisher alive for as long as the parent
173
+ // ran.
174
+ for (const daemon of this.daemons.values()) daemon.close();
175
+ this.daemons.clear();
143
176
  const error = new Error("@ttsc/graph: native session closed");
144
177
  if (this.child !== undefined) this.failChild(this.child, error);
145
178
  else this.failPending(error);
@@ -148,6 +181,7 @@ export class TtscGraphSession {
148
181
  private async refresh(signal?: AbortSignal): Promise<TtscGraphMemory> {
149
182
  // The protocol version and the envelope shape were both settled in onLine,
150
183
  // before this frame was ever routed here.
184
+ await this.republishArtifacts(signal);
151
185
  const response = await this.request(signal);
152
186
  this.assertResponseSemantics(response);
153
187
  if (response.error !== undefined) {
@@ -206,6 +240,58 @@ export class TtscGraphSession {
206
240
  throw error;
207
241
  }
208
242
 
243
+ /**
244
+ * Re-derive the artifact set when the documents or configuration behind it
245
+ * moved, before the request that would otherwise answer with the old one.
246
+ *
247
+ * A resident session is invalidated by the compiler's own build universe, and
248
+ * none of this is in it: the documents a rule reads are not Program inputs,
249
+ * which is the property that keeps a Markdown edit from costing a typecheck.
250
+ * The cost of that property is that nothing else notices the edit at all, so
251
+ * this is what notices it.
252
+ *
253
+ * Only the overlay is replaced. The child is not restarted and the Program is
254
+ * not reloaded — the native session compares the file it is handed against
255
+ * the one it applied, and re-projects the resident program when they differ.
256
+ *
257
+ * Asked of sidecars this session keeps open, and awaited rather than blocking
258
+ * the event loop. A republish still costs the rule a Program — the daemon is
259
+ * told to drop its warm one, because the sources a claim activates against
260
+ * have been edited too — but not a process, a plugin load and a configuration
261
+ * evaluation per verb. An already-cancelled request does not start one.
262
+ */
263
+ private async republishArtifacts(signal?: AbortSignal): Promise<void> {
264
+ if (signal?.aborted) return;
265
+ // A child yet to be spawned publishes on the way up, so there is nothing
266
+ // here to keep fresh until one does.
267
+ if (this.child === undefined || this.artifacts === undefined) return;
268
+ if (!artifactsAreStale(this.artifacts)) return;
269
+ const next = await publishArtifactsResident(
270
+ { cwd: this.cwd, tsconfig: this.tsconfig },
271
+ (plugin) => this.daemon(plugin),
272
+ );
273
+ // The new answer is taken whatever it says, including that the project now
274
+ // publishes nothing. Keeping the old set on a `null` would be guessing that
275
+ // the publisher failed rather than that it was removed, and guessing wrong
276
+ // in that direction is the unrecoverable one: a session that answers with
277
+ // artifacts from a plugin the user deleted keeps doing so until it is
278
+ // restarted, while a transient failure is repaired by the next edit.
279
+ this.artifacts = next;
280
+ }
281
+
282
+ /** The open sidecar for one plugin, opened on first use. */
283
+ private daemon(plugin: {
284
+ binary: string;
285
+ manifest: string;
286
+ projectContext?: string;
287
+ }): TtscLintDaemon {
288
+ const open = this.daemons.get(plugin.binary);
289
+ if (open !== undefined) return open;
290
+ const created = new TtscLintDaemon(plugin, this.cwd, this.tsconfig);
291
+ this.daemons.set(plugin.binary, created);
292
+ return created;
293
+ }
294
+
209
295
  private request(signal?: AbortSignal): Promise<ITtscGraphSnapshot> {
210
296
  if (signal?.aborted) throw cancelledError(signal);
211
297
  const child = this.ensureChild();
@@ -228,7 +314,19 @@ export class TtscGraphSession {
228
314
  return;
229
315
  }
230
316
  child.process.stdin.write(
231
- `${JSON.stringify({ id, graphSnapshotVersion: GRAPH_SNAPSHOT_PROTOCOL_VERSION })}\n`,
317
+ `${JSON.stringify({
318
+ id,
319
+ graphSnapshotVersion: GRAPH_SNAPSHOT_PROTOCOL_VERSION,
320
+ // Empty when the project publishes nothing, which withdraws whatever
321
+ // the server holds: a publisher the user removed must stop being
322
+ // answered with, and omitting the field instead would say only that
323
+ // this client has no opinion. Omitted only before a child exists,
324
+ // which no request reaches.
325
+ artifacts:
326
+ this.artifacts === undefined
327
+ ? undefined
328
+ : (this.artifacts.file ?? ""),
329
+ })}\n`,
232
330
  (error) => {
233
331
  if (error === null || error === undefined) return;
234
332
  if (this.pending.get(id) !== pending) return;
@@ -256,9 +354,21 @@ export class TtscGraphSession {
256
354
  ) {
257
355
  return this.child;
258
356
  }
357
+ const artifacts = publishArtifacts({
358
+ cwd: this.cwd,
359
+ tsconfig: this.tsconfig,
360
+ });
361
+ this.artifacts = artifacts;
259
362
  const process = spawn(
260
363
  this.binary,
261
- ["serve", "--cwd", this.cwd, "--tsconfig", this.tsconfig],
364
+ [
365
+ "serve",
366
+ "--cwd",
367
+ this.cwd,
368
+ "--tsconfig",
369
+ this.tsconfig,
370
+ ...(artifacts.file === null ? [] : ["--artifacts", artifacts.file]),
371
+ ],
262
372
  { stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
263
373
  );
264
374
  const lines = readline.createInterface({ input: process.stdout });
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
3
3
 
4
4
  import { ITtscGraphDump } from "../structures/ITtscGraphDump";
5
5
  import { ITtscGraphSnapshot } from "../structures/ITtscGraphSnapshot";
6
+ import { isArtifactNodeKind } from "../structures/TtscGraphArtifactNodeKind";
6
7
  import { DUMP_SCHEMA_VERSION } from "./loadGraph";
7
8
 
8
9
  /** Atomic validator and assembler for native `ttscgraph` shard transactions. */
@@ -323,8 +324,12 @@ function assertShardContents(
323
324
  }
324
325
  return;
325
326
  }
327
+ // The metadata shard carries the nodes no program source owns: external
328
+ // boundary leaves, and published artifacts. An artifact has no source to be
329
+ // owned by, so the guard that keeps authored declarations out of this shard
330
+ // names it rather than treating "not external" as "authored".
326
331
  for (const node of shard.nodes) {
327
- if (!node.external) {
332
+ if (!node.external && !isArtifactNodeKind(node.kind)) {
328
333
  throw new Error(
329
334
  `@ttsc/graph: native metadata shard ${key} owns authored node ${node.id}`,
330
335
  );
@@ -0,0 +1,179 @@
1
+ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
2
+ import readline from "node:readline";
3
+
4
+ /** One plugin sidecar this daemon can be opened against. */
5
+ export interface ITtscLintDaemonTarget {
6
+ binary: string;
7
+ manifest: string;
8
+ projectContext?: string;
9
+ }
10
+
11
+ /**
12
+ * A resident `@ttsc/lint` sidecar, kept open across the questions one graph
13
+ * session asks it.
14
+ *
15
+ * The alternative is what this replaces: a process per question. Publishing a
16
+ * project's artifacts asks two — `graph-nodes` and `project-inputs` — and a
17
+ * resident graph session asks both again every time a document moves, so the
18
+ * spawn, the plugin load, and the configuration evaluation were paid once per
19
+ * edit, forever. `@ttsc/lint` already runs `lsp-serve` for exactly this reason,
20
+ * and `ttscserver` already routes its own read verbs through it.
21
+ *
22
+ * Everything here degrades rather than fails. A sidecar built before these
23
+ * verbs joined the daemon rejects them, an older one does not know `lsp-serve`
24
+ * at all, and a daemon can die mid-session; each of those closes this one and
25
+ * leaves the caller to spawn per verb, which is the behaviour that existed
26
+ * before this and is still correct — only slower.
27
+ */
28
+ export class TtscLintDaemon {
29
+ private child: ChildProcessWithoutNullStreams | undefined;
30
+ private lines: readline.Interface | undefined;
31
+ private readonly pending: ((reply: IReply | null) => void)[] = [];
32
+ private queue: Promise<unknown> = Promise.resolve();
33
+ private failed = false;
34
+
35
+ public constructor(
36
+ private readonly target: ITtscLintDaemonTarget,
37
+ private readonly cwd: string,
38
+ private readonly tsconfig: string,
39
+ ) {}
40
+
41
+ /**
42
+ * Ask one verb and return its raw JSON, or `null` when this daemon cannot
43
+ * answer it.
44
+ *
45
+ * `null` is always "ask the sidecar directly instead", never "the project has
46
+ * none". The two are indistinguishable downstream — an empty artifact set is
47
+ * the correct answer for most projects — so a daemon that cannot answer must
48
+ * never be allowed to look like one that answered nothing.
49
+ *
50
+ * Requests are serialized. The daemon answers one line per request in order,
51
+ * with nothing in the reply to address it by, so a second request in flight
52
+ * would be matched against the first one's answer.
53
+ */
54
+ public ask(verb: string, invalidate: boolean): Promise<string | null> {
55
+ const run = this.queue.then(() => this.send(verb, invalidate));
56
+ this.queue = run.catch(() => undefined);
57
+ return run;
58
+ }
59
+
60
+ /** Stop the sidecar. Safe to call more than once, and after a failure. */
61
+ public close(): void {
62
+ this.failed = true;
63
+ for (const settle of this.pending.splice(0)) settle(null);
64
+ this.lines?.close();
65
+ this.lines = undefined;
66
+ const child = this.child;
67
+ this.child = undefined;
68
+ if (child === undefined) return;
69
+ child.stdin.end();
70
+ child.kill();
71
+ }
72
+
73
+ private async send(
74
+ verb: string,
75
+ invalidate: boolean,
76
+ ): Promise<string | null> {
77
+ if (this.failed) return null;
78
+ const child = this.start();
79
+ if (child === undefined) return null;
80
+ const reply = await new Promise<IReply | null>((resolve) => {
81
+ this.pending.push(resolve);
82
+ child.stdin.write(
83
+ `${JSON.stringify({ invalidate, verb })}\n`,
84
+ (error) => {
85
+ if (error === null || error === undefined) return;
86
+ this.fail();
87
+ },
88
+ );
89
+ });
90
+ if (reply === null || reply.code !== 0) {
91
+ // A nonzero code is the sidecar declining, and this client cannot tell
92
+ // "unknown verb" from "the rule failed". Closing rather than retrying
93
+ // through the daemon is what makes the caller fall back to the direct
94
+ // command, where a real failure surfaces the same way it always did.
95
+ this.close();
96
+ return null;
97
+ }
98
+ return reply.result;
99
+ }
100
+
101
+ private start(): ChildProcessWithoutNullStreams | undefined {
102
+ if (this.child !== undefined) return this.child;
103
+ if (this.failed) return undefined;
104
+ let child: ChildProcessWithoutNullStreams;
105
+ try {
106
+ child = spawn(
107
+ this.target.binary,
108
+ [
109
+ "lsp-serve",
110
+ `--cwd=${this.cwd}`,
111
+ `--tsconfig=${this.tsconfig}`,
112
+ `--plugins-json=${this.target.manifest}`,
113
+ ...(this.target.projectContext === undefined
114
+ ? []
115
+ : [`--project-context-json=${this.target.projectContext}`]),
116
+ ],
117
+ { cwd: this.cwd, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
118
+ );
119
+ } catch {
120
+ this.failed = true;
121
+ return undefined;
122
+ }
123
+ this.child = child;
124
+ // The sidecar's stderr is its own diagnostic channel and is not this
125
+ // client's to interpret; draining it keeps a chatty plugin from filling the
126
+ // pipe and stalling the daemon it is talking through.
127
+ child.stderr.resume();
128
+ child.on("error", () => this.fail());
129
+ child.on("exit", () => this.fail());
130
+ this.lines = readline.createInterface({ input: child.stdout });
131
+ this.lines.on("line", (line) => this.onLine(line));
132
+ return child;
133
+ }
134
+
135
+ private onLine(line: string): void {
136
+ const settle = this.pending.shift();
137
+ if (settle === undefined) return;
138
+ let reply: IReply;
139
+ try {
140
+ reply = JSON.parse(line) as IReply;
141
+ } catch {
142
+ settle(null);
143
+ return;
144
+ }
145
+ settle(
146
+ typeof reply.code === "number"
147
+ ? { code: reply.code, result: rawResult(line) }
148
+ : null,
149
+ );
150
+ }
151
+
152
+ private fail(): void {
153
+ this.close();
154
+ }
155
+ }
156
+
157
+ /** One `lsp-serve` reply: a verb result and the code that qualifies it. */
158
+ interface IReply {
159
+ code: number;
160
+ result: string;
161
+ }
162
+
163
+ /**
164
+ * The `result` member, as the JSON text this daemon's callers parse.
165
+ *
166
+ * A verb's result is arbitrary JSON that the caller decodes itself, so it is
167
+ * handed back as text rather than as a value — which is what the direct command
168
+ * hands over, and what keeps the two paths interchangeable. The text is
169
+ * re-serialized rather than sliced out of the line: the bytes are not identical
170
+ * to the sidecar's own, but the value they decode to is, and no caller here
171
+ * reads anything else.
172
+ *
173
+ * A reply with no `result` is `"null"`, so a caller parses a value either way
174
+ * instead of being handed the empty string.
175
+ */
176
+ function rawResult(line: string): string {
177
+ const parsed = JSON.parse(line) as { result?: unknown };
178
+ return parsed.result === undefined ? "null" : JSON.stringify(parsed.result);
179
+ }
@@ -5,6 +5,7 @@ import { captureProcessOutput, ensureExecutable } from "../nativeExecutable";
5
5
  import { resolveGraphBinary } from "../resolveGraphBinary";
6
6
  import { ITtscGraphDump } from "../structures/ITtscGraphDump";
7
7
  import { TtscGraphMemory } from "./TtscGraphMemory";
8
+ import { publishArtifacts } from "./publishedArtifacts";
8
9
 
9
10
  /**
10
11
  * The dump schema version this client reads.
@@ -18,8 +19,15 @@ import { TtscGraphMemory } from "./TtscGraphMemory";
18
19
  * protocol and has to hold it to the same number: the envelope's version and
19
20
  * the body's are independent, so a producer can speak this protocol and still
20
21
  * send a body from another schema.
22
+ *
23
+ * `scripts/assert-ttscgraph-release-candidate.cjs` reads this declaration out
24
+ * of this file with a regular expression, because it runs against a packaged
25
+ * binary in a workflow that has no reason to have built this package first.
26
+ * Rewriting the declaration — adding a type annotation, splitting the line —
27
+ * makes that script fail loudly rather than silently, and its message names
28
+ * this constant; update its pattern with any such change.
21
29
  */
22
- export const DUMP_SCHEMA_VERSION = 6;
30
+ export const DUMP_SCHEMA_VERSION = 8;
23
31
 
24
32
  /**
25
33
  * Build the resident {@link TtscGraphMemory} for a project by running `ttscgraph
@@ -67,10 +75,26 @@ export function loadGraph(
67
75
  let stdout: string;
68
76
  let stderr: string;
69
77
  try {
70
- result = spawnSync(binary, ["dump", "--cwd", cwd, "--tsconfig", tsconfig], {
71
- stdio: ["ignore", capture.stdoutFd, capture.stderrFd],
72
- windowsHide: true,
73
- });
78
+ // Ask the project's lint install for the artifacts a citation can name
79
+ // before the dump runs, so the producer can resolve a documentation target
80
+ // into a relation rather than leaving it a token. A project with no lint
81
+ // install, or none that publishes, contributes no file and no claim.
82
+ const artifacts = publishArtifacts({ cwd, tsconfig });
83
+ result = spawnSync(
84
+ binary,
85
+ [
86
+ "dump",
87
+ "--cwd",
88
+ cwd,
89
+ "--tsconfig",
90
+ tsconfig,
91
+ ...(artifacts.file === null ? [] : ["--artifacts", artifacts.file]),
92
+ ],
93
+ {
94
+ stdio: ["ignore", capture.stdoutFd, capture.stderrFd],
95
+ windowsHide: true,
96
+ },
97
+ );
74
98
  stdout = capture.read("stdout");
75
99
  stderr = capture.read("stderr");
76
100
  } finally {