@ttsc/graph 0.26.2 → 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.
- package/README.md +3 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +15 -3
- package/lib/index.js.map +1 -1
- package/lib/model/TtscGraphMemory.d.ts +26 -0
- package/lib/model/TtscGraphMemory.js +121 -2
- package/lib/model/TtscGraphMemory.js.map +1 -1
- package/lib/model/TtscGraphSession.d.ts +23 -0
- package/lib/model/TtscGraphSession.js +248 -96
- package/lib/model/TtscGraphSession.js.map +1 -1
- package/lib/model/TtscGraphShardStore.js +6 -1
- package/lib/model/TtscGraphShardStore.js.map +1 -1
- package/lib/model/TtscLintDaemon.d.ts +54 -0
- package/lib/model/TtscLintDaemon.js +166 -0
- package/lib/model/TtscLintDaemon.js.map +1 -0
- package/lib/model/loadGraph.d.ts +8 -1
- package/lib/model/loadGraph.js +144 -72
- package/lib/model/loadGraph.js.map +1 -1
- package/lib/model/publishedArtifacts.d.ts +142 -0
- package/lib/model/publishedArtifacts.js +403 -0
- package/lib/model/publishedArtifacts.js.map +1 -0
- package/lib/nativeExecutable.js +17 -1
- package/lib/nativeExecutable.js.map +1 -1
- package/lib/reduce.js +20 -7
- package/lib/reduce.js.map +1 -1
- package/lib/server/createServer.js +38 -2
- package/lib/server/createServer.js.map +1 -1
- package/lib/server/runDetails.d.ts +15 -0
- package/lib/server/runDetails.js +48 -0
- package/lib/server/runDetails.js.map +1 -1
- package/lib/server/runEntrypoints.js +7 -1
- package/lib/server/runEntrypoints.js.map +1 -1
- package/lib/server/runLookup.js +129 -11
- package/lib/server/runLookup.js.map +1 -1
- package/lib/structures/ITtscGraphApplication.d.ts +3 -1
- package/lib/structures/ITtscGraphDetails.d.ts +12 -0
- package/lib/structures/ITtscGraphDocTag.d.ts +44 -0
- package/lib/structures/ITtscGraphDocTag.js +3 -0
- package/lib/structures/ITtscGraphDocTag.js.map +1 -0
- package/lib/structures/ITtscGraphDump.d.ts +23 -4
- package/lib/structures/ITtscGraphLookup.d.ts +29 -0
- package/lib/structures/ITtscGraphNode.d.ts +25 -0
- package/lib/structures/TtscGraphArtifactNodeKind.d.ts +23 -0
- package/lib/structures/TtscGraphArtifactNodeKind.js +37 -0
- package/lib/structures/TtscGraphArtifactNodeKind.js.map +1 -0
- package/lib/structures/TtscGraphDumpEdgeKind.d.ts +1 -1
- package/lib/structures/TtscGraphDumpNodeKind.d.ts +10 -2
- package/lib/structures/TtscGraphEdgeKind.d.ts +8 -1
- package/lib/structures/TtscGraphNodeKind.d.ts +1 -1
- package/lib/structures/index.d.ts +2 -0
- package/lib/structures/index.js +2 -0
- package/lib/structures/index.js.map +1 -1
- package/lib/view.js +17 -1
- package/lib/view.js.map +1 -1
- package/lib/viewer/index.html +5 -13
- package/lib/viewer/viewer.js +190 -190
- package/package.json +5 -5
- package/src/index.ts +23 -6
- package/src/model/TtscGraphMemory.ts +118 -2
- package/src/model/TtscGraphSession.ts +112 -2
- package/src/model/TtscGraphShardStore.ts +6 -1
- package/src/model/TtscLintDaemon.ts +179 -0
- package/src/model/loadGraph.ts +29 -5
- package/src/model/publishedArtifacts.ts +548 -0
- package/src/nativeExecutable.ts +24 -1
- package/src/reduce.ts +20 -7
- package/src/server/runDetails.ts +52 -1
- package/src/server/runEntrypoints.ts +7 -1
- package/src/server/runLookup.ts +139 -14
- package/src/structures/ITtscGraphApplication.ts +3 -1
- package/src/structures/ITtscGraphDetails.ts +13 -0
- package/src/structures/ITtscGraphDocTag.ts +45 -0
- package/src/structures/ITtscGraphDump.ts +25 -4
- package/src/structures/ITtscGraphLookup.ts +31 -0
- package/src/structures/ITtscGraphNode.ts +27 -0
- package/src/structures/TtscGraphArtifactNodeKind.ts +35 -0
- package/src/structures/TtscGraphDumpEdgeKind.ts +1 -0
- package/src/structures/TtscGraphDumpNodeKind.ts +16 -2
- package/src/structures/TtscGraphEdgeKind.ts +8 -0
- package/src/structures/TtscGraphNodeKind.ts +7 -1
- package/src/structures/index.ts +2 -0
- package/src/view.ts +17 -1
- package/src/viewer/index.html +5 -13
- package/src/viewer/legend.ts +107 -0
- 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.
|
|
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": "
|
|
20
|
-
"typia": "
|
|
19
|
+
"@typia/mcp": "14.0.0",
|
|
20
|
+
"typia": "14.0.0"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
|
-
"ttsc": "^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.
|
|
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(
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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({
|
|
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
|
-
[
|
|
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
|
+
}
|
package/src/model/loadGraph.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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 {
|