@orkestrel/probe 0.0.1
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/LICENSE +21 -0
- package/README.md +81 -0
- package/dist/bin/main.js +14 -0
- package/dist/bin/main.js.map +1 -0
- package/dist/src/core/index.cjs +735 -0
- package/dist/src/core/index.cjs.map +1 -0
- package/dist/src/core/index.d.cts +1134 -0
- package/dist/src/core/index.d.ts +1134 -0
- package/dist/src/core/index.js +706 -0
- package/dist/src/core/index.js.map +1 -0
- package/dist/src/server/index.cjs +2346 -0
- package/dist/src/server/index.cjs.map +1 -0
- package/dist/src/server/index.d.cts +941 -0
- package/dist/src/server/index.d.ts +941 -0
- package/dist/src/server/index.js +2321 -0
- package/dist/src/server/index.js.map +1 -0
- package/package.json +131 -0
|
@@ -0,0 +1,2346 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let node_fs = require("node:fs");
|
|
3
|
+
let node_crypto = require("node:crypto");
|
|
4
|
+
let node_module = require("node:module");
|
|
5
|
+
let node_path = require("node:path");
|
|
6
|
+
let _orkestrel_contract = require("@orkestrel/contract");
|
|
7
|
+
let _src_core = require("../core/index.cjs");
|
|
8
|
+
let _orkestrel_emitter = require("@orkestrel/emitter");
|
|
9
|
+
let _orkestrel_queue = require("@orkestrel/queue");
|
|
10
|
+
let _orkestrel_timeout = require("@orkestrel/timeout");
|
|
11
|
+
let node_child_process = require("node:child_process");
|
|
12
|
+
let node_url = require("node:url");
|
|
13
|
+
let node_stream = require("node:stream");
|
|
14
|
+
let node_timers_promises = require("node:timers/promises");
|
|
15
|
+
let _orkestrel_mcp = require("@orkestrel/mcp");
|
|
16
|
+
let _orkestrel_mcp_server = require("@orkestrel/mcp/server");
|
|
17
|
+
let _orkestrel_tool = require("@orkestrel/tool");
|
|
18
|
+
//#region src/server/helpers.ts
|
|
19
|
+
/**
|
|
20
|
+
* Rewrites one path into the forward-slash spelling this package compares and reports paths in.
|
|
21
|
+
*
|
|
22
|
+
* @remarks
|
|
23
|
+
* Windows reports a path with backslashes and accepts either separator, so two spellings of one
|
|
24
|
+
* file compare unequal until they are rewritten. Every path this package keys a map on, matches a
|
|
25
|
+
* prefix against, or hands a caller passes through here first.
|
|
26
|
+
*
|
|
27
|
+
* @param path - The path to rewrite
|
|
28
|
+
* @returns The same path with every backslash replaced by a forward slash
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* normalizePath('src\\core\\greeting.ts') // 'src/core/greeting.ts'
|
|
33
|
+
* normalizePath('src/core/greeting.ts') // 'src/core/greeting.ts'
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
function normalizePath(path) {
|
|
37
|
+
return path.replaceAll("\\", "/");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Resolves a path inside a target workspace and rejects traversal outside it.
|
|
41
|
+
*
|
|
42
|
+
* @remarks
|
|
43
|
+
* A native `ENAMETOOLONG` or NUL-byte `ERR_INVALID_ARG_VALUE` fault is a claimant refusal. This
|
|
44
|
+
* classifies the fault, not the author: a workspace nested deeply enough that this package's short
|
|
45
|
+
* generated names overflow the host limit also reads as a claimant refusal.
|
|
46
|
+
*
|
|
47
|
+
* @param workspace - The target workspace root
|
|
48
|
+
* @param target - A workspace-relative path
|
|
49
|
+
* @param mutate - If `true`, refuses a symbolic link in an existing descendant; if `false`,
|
|
50
|
+
* resolves lexically. Default: `false`
|
|
51
|
+
* @returns The absolute contained path
|
|
52
|
+
* @throws When the path resolves outside the workspace, mutation would cross a symbolic link, or
|
|
53
|
+
* the target tree cannot be inspected
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```ts
|
|
57
|
+
* resolveWorkspaceFile('/srv/checkout', 'src/core/greeting.ts')
|
|
58
|
+
* // '/srv/checkout/src/core/greeting.ts'
|
|
59
|
+
* resolveWorkspaceFile('/srv/checkout', '../secrets.env')
|
|
60
|
+
* // throws: Path escapes the workspace: ../secrets.env
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
function resolveWorkspaceFile(workspace, target, mutate = false) {
|
|
64
|
+
const root = (0, node_path.resolve)(workspace);
|
|
65
|
+
const file = (0, node_path.resolve)(root, target);
|
|
66
|
+
const path = (0, node_path.relative)(root, file);
|
|
67
|
+
if (path === "" || path === ".." || path.startsWith(`..${node_path.sep}`) || (0, node_path.isAbsolute)(path)) throw new _src_core.ProbeError(`Path escapes the workspace: ${target}`, {
|
|
68
|
+
origin: "claimant",
|
|
69
|
+
code: "refused",
|
|
70
|
+
context: { path: target }
|
|
71
|
+
});
|
|
72
|
+
if (!mutate) return file;
|
|
73
|
+
try {
|
|
74
|
+
const canonical = (0, node_fs.realpathSync)(root);
|
|
75
|
+
let descendant = root;
|
|
76
|
+
for (const segment of path.split(node_path.sep)) {
|
|
77
|
+
descendant = (0, node_path.resolve)(descendant, segment);
|
|
78
|
+
const outcome = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(descendant));
|
|
79
|
+
if (!outcome.success) {
|
|
80
|
+
const error = outcome.error;
|
|
81
|
+
if (typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR")) break;
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
if (outcome.value.isSymbolicLink()) throw new _src_core.ProbeError(`Path crosses a symbolic link: ${target}`, {
|
|
85
|
+
origin: "workspace",
|
|
86
|
+
code: "refused",
|
|
87
|
+
context: { path: target }
|
|
88
|
+
});
|
|
89
|
+
const resolved = (0, node_fs.realpathSync)(descendant);
|
|
90
|
+
const remainder = (0, node_path.relative)(canonical, resolved);
|
|
91
|
+
if (remainder === ".." || remainder.startsWith(`..${node_path.sep}`) || (0, node_path.isAbsolute)(remainder)) throw new _src_core.ProbeError(`Path escapes the workspace: ${target}`, {
|
|
92
|
+
origin: "claimant",
|
|
93
|
+
code: "refused",
|
|
94
|
+
context: { path: target }
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error instanceof _src_core.ProbeError) throw error;
|
|
99
|
+
const claimant = typeof error === "object" && error !== null && "code" in error && (error.code === "ENAMETOOLONG" || error.code === "ERR_INVALID_ARG_VALUE");
|
|
100
|
+
throw new _src_core.ProbeError(`The workspace path cannot be inspected: ${target}`, {
|
|
101
|
+
origin: claimant ? "claimant" : "workspace",
|
|
102
|
+
code: claimant ? "refused" : "malformed",
|
|
103
|
+
context: { path: target },
|
|
104
|
+
cause: error
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return file;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Projects an absolute tool path into the workspace-relative form issues expose.
|
|
111
|
+
*
|
|
112
|
+
* @param workspace - The target workspace root
|
|
113
|
+
* @param file - The path a tool reported
|
|
114
|
+
* @returns A forward-slash workspace-relative path
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* ```ts
|
|
118
|
+
* relativeWorkspaceFile('/srv/checkout', '/srv/checkout/src/core/greeting.ts')
|
|
119
|
+
* // 'src/core/greeting.ts'
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
function relativeWorkspaceFile(workspace, file) {
|
|
123
|
+
return normalizePath((0, node_path.relative)((0, node_path.resolve)(workspace), (0, node_path.resolve)(file)));
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Resolves one installed module from the target workspace.
|
|
127
|
+
*
|
|
128
|
+
* @param workspace - The target workspace root
|
|
129
|
+
* @param specifier - The module specifier to resolve
|
|
130
|
+
* @returns The installed module entry path
|
|
131
|
+
* @throws When the workspace does not install the module
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```ts
|
|
135
|
+
* resolveWorkspaceModule(process.cwd(), 'typescript/package.json') ===
|
|
136
|
+
* readWorkspaceManifest(process.cwd(), 'typescript').path // true
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
function resolveWorkspaceModule(workspace, specifier) {
|
|
140
|
+
const outcome = (0, _orkestrel_contract.attempt)(() => (0, node_module.createRequire)((0, node_path.resolve)(workspace, "package.json")).resolve(specifier));
|
|
141
|
+
if (outcome.success) return outcome.value;
|
|
142
|
+
const error = outcome.error;
|
|
143
|
+
const missing = typeof error === "object" && error !== null && "code" in error && error.code === "MODULE_NOT_FOUND";
|
|
144
|
+
throw new _src_core.ProbeError(`The workspace cannot resolve ${specifier}`, {
|
|
145
|
+
origin: "workspace",
|
|
146
|
+
code: missing ? "missing" : "malformed",
|
|
147
|
+
context: { name: specifier },
|
|
148
|
+
cause: error
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
function loadWorkspaceModule(workspace, specifier) {
|
|
152
|
+
const outcome = (0, _orkestrel_contract.attempt)(() => {
|
|
153
|
+
const require$1 = (0, node_module.createRequire)((0, node_path.resolve)(workspace, "package.json"));
|
|
154
|
+
return specifier === "typescript" ? require$1("typescript") : require$1("vitest/node");
|
|
155
|
+
});
|
|
156
|
+
if (outcome.success) return outcome.value;
|
|
157
|
+
const error = outcome.error;
|
|
158
|
+
const missing = typeof error === "object" && error !== null && "code" in error && error.code === "MODULE_NOT_FOUND";
|
|
159
|
+
throw new _src_core.ProbeError(`The workspace cannot load ${specifier}`, {
|
|
160
|
+
origin: "workspace",
|
|
161
|
+
code: missing ? "missing" : "malformed",
|
|
162
|
+
context: { name: specifier },
|
|
163
|
+
cause: error
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Reads one installed package manifest from the target workspace.
|
|
168
|
+
*
|
|
169
|
+
* @param workspace - The target workspace root
|
|
170
|
+
* @param name - The installed package name
|
|
171
|
+
* @returns The manifest's absolute path and parsed record
|
|
172
|
+
* @throws When the package cannot be resolved, read, parsed, or does not publish a record manifest
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* ```ts
|
|
176
|
+
* const manifest = readWorkspaceManifest(process.cwd(), 'typescript')
|
|
177
|
+
* console.log(manifest.contents.version)
|
|
178
|
+
* ```
|
|
179
|
+
*/
|
|
180
|
+
function readWorkspaceManifest(workspace, name) {
|
|
181
|
+
let path;
|
|
182
|
+
try {
|
|
183
|
+
path = resolveWorkspaceModule(workspace, `${name}/package.json`);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (!(error instanceof _src_core.ProbeError)) throw error;
|
|
186
|
+
throw new _src_core.ProbeError(`${name} does not publish a readable manifest`, {
|
|
187
|
+
origin: "workspace",
|
|
188
|
+
code: error.code,
|
|
189
|
+
context: { name },
|
|
190
|
+
cause: error.cause
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const reading = (0, _orkestrel_contract.attempt)(() => (0, node_fs.readFileSync)(path, "utf8"));
|
|
194
|
+
if (!reading.success) {
|
|
195
|
+
const error = reading.error;
|
|
196
|
+
const missing = typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
|
|
197
|
+
throw new _src_core.ProbeError(`${name} does not publish a readable manifest`, {
|
|
198
|
+
origin: "workspace",
|
|
199
|
+
code: missing ? "missing" : "malformed",
|
|
200
|
+
context: {
|
|
201
|
+
name,
|
|
202
|
+
path
|
|
203
|
+
},
|
|
204
|
+
cause: error
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
const parsing = (0, _orkestrel_contract.attempt)(() => JSON.parse(reading.value));
|
|
208
|
+
if (!parsing.success) throw new _src_core.ProbeError(`${name} does not publish a readable manifest`, {
|
|
209
|
+
origin: "workspace",
|
|
210
|
+
code: "malformed",
|
|
211
|
+
context: {
|
|
212
|
+
name,
|
|
213
|
+
path
|
|
214
|
+
},
|
|
215
|
+
cause: parsing.error
|
|
216
|
+
});
|
|
217
|
+
const contents = parsing.value;
|
|
218
|
+
if (!(0, _orkestrel_contract.isRecord)(contents)) throw new _src_core.ProbeError(`${name} does not publish a readable manifest`, {
|
|
219
|
+
origin: "workspace",
|
|
220
|
+
code: "malformed",
|
|
221
|
+
context: {
|
|
222
|
+
name,
|
|
223
|
+
path
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
return {
|
|
227
|
+
path,
|
|
228
|
+
contents
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Resolves a package's portable JavaScript binary from the target workspace.
|
|
233
|
+
*
|
|
234
|
+
* @param workspace - The target workspace root
|
|
235
|
+
* @param name - The installed package name and binary key
|
|
236
|
+
* @returns The absolute JavaScript entry named by the package's `bin` field
|
|
237
|
+
* @throws When the package does not publish a binary under the requested name
|
|
238
|
+
*
|
|
239
|
+
* @example
|
|
240
|
+
* ```ts
|
|
241
|
+
* relativeWorkspaceFile(process.cwd(), resolveWorkspaceBinary(process.cwd(), 'oxlint'))
|
|
242
|
+
* // 'node_modules/oxlint/bin/oxlint'
|
|
243
|
+
* resolveWorkspaceBinary(process.cwd(), 'typescript')
|
|
244
|
+
* // throws: typescript does not publish the typescript binary
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
function resolveWorkspaceBinary(workspace, name) {
|
|
248
|
+
const manifest = readWorkspaceManifest(workspace, name);
|
|
249
|
+
const bin = manifest.contents.bin;
|
|
250
|
+
if (bin === void 0) throw new _src_core.ProbeError(`${name} does not publish a bin field`, {
|
|
251
|
+
origin: "workspace",
|
|
252
|
+
code: "missing",
|
|
253
|
+
context: { name }
|
|
254
|
+
});
|
|
255
|
+
if (typeof bin === "string") return (0, node_path.resolve)(manifest.path, "..", bin);
|
|
256
|
+
if (!(0, _orkestrel_contract.isRecord)(bin) || !(name in bin)) throw new _src_core.ProbeError(`${name} does not publish the ${name} binary`, {
|
|
257
|
+
origin: "workspace",
|
|
258
|
+
code: "missing",
|
|
259
|
+
context: { name }
|
|
260
|
+
});
|
|
261
|
+
const entry = bin[name];
|
|
262
|
+
if (typeof entry !== "string") throw new _src_core.ProbeError(`${name} publishes an invalid ${name} binary`, {
|
|
263
|
+
origin: "workspace",
|
|
264
|
+
code: "malformed",
|
|
265
|
+
context: {
|
|
266
|
+
name,
|
|
267
|
+
value: entry
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
return (0, node_path.resolve)(manifest.path, "..", entry);
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Selects the scoped TypeScript project for one candidate draft path.
|
|
274
|
+
*
|
|
275
|
+
* @param path - The workspace-relative candidate draft path
|
|
276
|
+
* @returns The workspace-relative scoped project path
|
|
277
|
+
* @throws When the path does not name a configured source or application environment
|
|
278
|
+
*
|
|
279
|
+
* @example
|
|
280
|
+
* ```ts
|
|
281
|
+
* inferTypeProject('src/core/greeting.ts') // 'configs/src/tsconfig.core.json'
|
|
282
|
+
* inferTypeProject('tests/src/core/greeting.test.ts')
|
|
283
|
+
* // throws: Cannot infer a scoped TypeScript project for tests/src/core/greeting.test.ts
|
|
284
|
+
* ```
|
|
285
|
+
*/
|
|
286
|
+
function inferTypeProject(path) {
|
|
287
|
+
const [axis, environment] = normalizePath(path).split("/");
|
|
288
|
+
if (axis !== "src" && axis !== "app" || environment === void 0 || environment === "") throw new _src_core.ProbeError(`Cannot infer a scoped TypeScript project for ${path}`, {
|
|
289
|
+
origin: "claimant",
|
|
290
|
+
code: "refused",
|
|
291
|
+
context: { path }
|
|
292
|
+
});
|
|
293
|
+
return `configs/${axis}/tsconfig.${environment}.json`;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Selects the Vitest project whose environment matches one test path.
|
|
297
|
+
*
|
|
298
|
+
* @remarks
|
|
299
|
+
* There is no root-project fallback. A path no configured project collects returns `undefined`,
|
|
300
|
+
* and the runtime stage refuses that claimant test with `code: 'missing'` rather than running it
|
|
301
|
+
* somewhere the workspace never configured.
|
|
302
|
+
*
|
|
303
|
+
* @param path - The workspace-relative test path
|
|
304
|
+
* @returns The matching project's name, or `undefined` when no configured project collects the path
|
|
305
|
+
*
|
|
306
|
+
* @example
|
|
307
|
+
* ```ts
|
|
308
|
+
* inferTestProject('tmp/probe/greeting.test.ts') // 'probe'
|
|
309
|
+
* inferTestProject('tests/src/server/helpers.test.ts') // 'src:server'
|
|
310
|
+
* inferTestProject('tests/config.test.ts') // undefined
|
|
311
|
+
* ```
|
|
312
|
+
*/
|
|
313
|
+
function inferTestProject(path) {
|
|
314
|
+
const [root, axis, environment] = normalizePath(path).split("/");
|
|
315
|
+
if (root === "tmp" && axis === "probe") return "probe";
|
|
316
|
+
if (root !== "tests" || axis === void 0 || environment === void 0) return void 0;
|
|
317
|
+
if (axis !== "src" && axis !== "app") return void 0;
|
|
318
|
+
return `${axis}:${environment}`;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Selects the Language Server Protocol language identifier for a source path.
|
|
322
|
+
*
|
|
323
|
+
* @param path - The source path whose extension selects the language
|
|
324
|
+
* @returns The matching JavaScript or TypeScript language identifier
|
|
325
|
+
*
|
|
326
|
+
* @example
|
|
327
|
+
* ```ts
|
|
328
|
+
* inferDocumentLanguage('src/core/greeting.ts') // 'typescript'
|
|
329
|
+
* inferDocumentLanguage('src/browser/Panel.tsx') // 'typescriptreact'
|
|
330
|
+
* ```
|
|
331
|
+
*/
|
|
332
|
+
function inferDocumentLanguage(path) {
|
|
333
|
+
const extension = (0, node_path.extname)(path).toLowerCase();
|
|
334
|
+
if (extension === ".tsx") return "typescriptreact";
|
|
335
|
+
if (extension === ".js" || extension === ".mjs" || extension === ".cjs") return "javascript";
|
|
336
|
+
if (extension === ".jsx") return "javascriptreact";
|
|
337
|
+
return "typescript";
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Creates a fresh sibling identity while preserving a test's resolution directory.
|
|
341
|
+
*
|
|
342
|
+
* @param workspace - The target workspace root
|
|
343
|
+
* @param path - The workspace-relative test path
|
|
344
|
+
* @param revision - The fresh revision identity
|
|
345
|
+
* @returns An absolute sibling file path
|
|
346
|
+
*
|
|
347
|
+
* @example
|
|
348
|
+
* ```ts
|
|
349
|
+
* relativeWorkspaceFile(
|
|
350
|
+
* '/srv/checkout',
|
|
351
|
+
* createRevisionFile('/srv/checkout', 'tmp/probe/greeting.test.ts', '4821-9f0c'),
|
|
352
|
+
* )
|
|
353
|
+
* // 'tmp/probe/greeting.test.probe-4821-9f0c.ts'
|
|
354
|
+
* ```
|
|
355
|
+
*/
|
|
356
|
+
function createRevisionFile(workspace, path, revision) {
|
|
357
|
+
const file = resolveWorkspaceFile(workspace, path);
|
|
358
|
+
const extension = (0, node_path.extname)(file);
|
|
359
|
+
return `${extension === "" ? file : file.slice(0, -extension.length)}.probe-${revision}${extension}`;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Reports whether a path is a workspace module Vitest can cache.
|
|
363
|
+
*
|
|
364
|
+
* @param path - The candidate file path
|
|
365
|
+
* @returns True for script, TypeScript, Vue, and JSON modules; false otherwise
|
|
366
|
+
*
|
|
367
|
+
* @example
|
|
368
|
+
* ```ts
|
|
369
|
+
* matchesWorkspaceModule('src/core/greeting.ts') // true
|
|
370
|
+
* matchesWorkspaceModule('src/styles/tokens.css') // false
|
|
371
|
+
* ```
|
|
372
|
+
*/
|
|
373
|
+
function matchesWorkspaceModule(path) {
|
|
374
|
+
return /\.(?:[cm]?[jt]sx?|vue|json)$/.test(path);
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Reads a JSON-RPC frame's declared byte length.
|
|
378
|
+
*
|
|
379
|
+
* @param header - The frame header text without its terminating empty line
|
|
380
|
+
* @returns The non-negative content length, or `undefined` for an invalid header
|
|
381
|
+
*
|
|
382
|
+
* @example
|
|
383
|
+
* ```ts
|
|
384
|
+
* parseContentLength('Content-Length: 128') // 128
|
|
385
|
+
* parseContentLength('Content-Type: application/json') // undefined
|
|
386
|
+
* ```
|
|
387
|
+
*/
|
|
388
|
+
function parseContentLength(header) {
|
|
389
|
+
const text = /(?:^|\r\n)Content-Length:\s*(\d+)(?:\r\n|$)/i.exec(header)?.[1];
|
|
390
|
+
if (text === void 0) return void 0;
|
|
391
|
+
const length = Number.parseInt(text, 10);
|
|
392
|
+
return Number.isSafeInteger(length) && length >= 0 ? length : void 0;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Normalizes a caught or foreign error into readable text.
|
|
396
|
+
*
|
|
397
|
+
* @param value - The value to describe
|
|
398
|
+
* @returns Its message when present, or its string representation
|
|
399
|
+
*
|
|
400
|
+
* @example
|
|
401
|
+
* ```ts
|
|
402
|
+
* describeUnknown(new Error('The lint stage has been destroyed'))
|
|
403
|
+
* // 'The lint stage has been destroyed'
|
|
404
|
+
* describeUnknown(17) // '17'
|
|
405
|
+
* ```
|
|
406
|
+
*/
|
|
407
|
+
function describeUnknown(value) {
|
|
408
|
+
if (value instanceof Error) return value.message;
|
|
409
|
+
if (typeof value === "object" && value !== null && "message" in value && typeof value.message === "string") return value.message;
|
|
410
|
+
return String(value);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Names every draft member of a claim-shaped value whose `path` this package's guard refuses.
|
|
414
|
+
*
|
|
415
|
+
* @remarks
|
|
416
|
+
* The published claim schema constrains `Draft.path` to a non-empty string and nothing else, while
|
|
417
|
+
* `isDraft` also refuses an absolute path and one that escapes the workspace. That one member is
|
|
418
|
+
* the whole of the difference between the advertised contract and the enforced one, so a caller
|
|
419
|
+
* refused after satisfying the schema is refused here and nowhere else. The whole input must first
|
|
420
|
+
* satisfy that schema. Each draft is then tested with `isDraft` itself rather than with a second
|
|
421
|
+
* copy of its rule. Reports nothing for a value carrying no draft member, including one refused for
|
|
422
|
+
* a member this contract does not declare.
|
|
423
|
+
*
|
|
424
|
+
* This function lives in the server helpers rather than in core: core's leaf pair, `helpers.ts` and
|
|
425
|
+
* `validators.ts`, may not consume the shapes module this function reads `CLAIM_SHAPE` from, and
|
|
426
|
+
* `ProbeServer` is the sole consumer of the result it returns.
|
|
427
|
+
*
|
|
428
|
+
* @param value - The rejected tool input
|
|
429
|
+
* @returns The dotted member names, in `case` then `control` order, or an empty list
|
|
430
|
+
*
|
|
431
|
+
* @example
|
|
432
|
+
* ```ts
|
|
433
|
+
* const test = { path: 'tmp/probe/greeting.test.ts', text: '' }
|
|
434
|
+
* findRefusedPaths({
|
|
435
|
+
* project: 'tsconfig.json',
|
|
436
|
+
* case: { files: [{ path: '../../etc/hosts', text: '' }], test },
|
|
437
|
+
* control: { files: [], test, stage: 'type', reason: 'must not compile' },
|
|
438
|
+
* })
|
|
439
|
+
* // ['case.files.0.path']
|
|
440
|
+
* ```
|
|
441
|
+
*/
|
|
442
|
+
function findRefusedPaths(value) {
|
|
443
|
+
if (!(0, _orkestrel_contract.compileGuard)(_src_core.CLAIM_SHAPE)(value)) return [];
|
|
444
|
+
const members = [];
|
|
445
|
+
for (const phase of ["case", "control"]) {
|
|
446
|
+
const subject = value[phase];
|
|
447
|
+
const drafts = /* @__PURE__ */ new Map([[`${phase}.test`, subject.test]]);
|
|
448
|
+
for (const [index, file] of subject.files.entries()) drafts.set(`${phase}.files.${index}`, file);
|
|
449
|
+
for (const [member, draft] of drafts) {
|
|
450
|
+
if ((0, _src_core.isDraft)(draft)) continue;
|
|
451
|
+
members.push(`${member}.path`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return members;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Rewrites every workspace-contained absolute path in a value to its workspace-relative form and
|
|
458
|
+
* sorts every record's keys.
|
|
459
|
+
*
|
|
460
|
+
* @remarks
|
|
461
|
+
* A parsed TypeScript project carries the absolute root it was parsed at, so the same commit
|
|
462
|
+
* checked out at two paths produces two values that describe one configuration. Rewriting those
|
|
463
|
+
* members makes the value portable and stops a host path leaking into anything derived from it. A
|
|
464
|
+
* path that escapes the workspace is left as it stands, because rewriting it would name a file
|
|
465
|
+
* outside the tree by a relative spelling. Key order is the parser's, not the configuration's, so
|
|
466
|
+
* sorting removes a difference that means nothing.
|
|
467
|
+
*
|
|
468
|
+
* @param workspace - The target workspace root
|
|
469
|
+
* @param value - The value to rewrite
|
|
470
|
+
* @returns The value with contained absolute paths made relative and every record key sorted
|
|
471
|
+
*
|
|
472
|
+
* @example
|
|
473
|
+
* ```ts
|
|
474
|
+
* normalizeValue('/srv/checkout', { rootDir: '/srv/checkout/src/core', strict: true })
|
|
475
|
+
* // { rootDir: 'src/core', strict: true }
|
|
476
|
+
* ```
|
|
477
|
+
*/
|
|
478
|
+
function normalizeValue(workspace, value) {
|
|
479
|
+
const root = (0, node_path.resolve)(workspace);
|
|
480
|
+
if (typeof value === "string") {
|
|
481
|
+
if (!(0, node_path.isAbsolute)(value)) return value;
|
|
482
|
+
const path = (0, node_path.relative)(root, (0, node_path.resolve)(value));
|
|
483
|
+
if (path === "") return ".";
|
|
484
|
+
if (path === ".." || path.startsWith(`..${node_path.sep}`) || (0, node_path.isAbsolute)(path)) return value;
|
|
485
|
+
return normalizePath(path);
|
|
486
|
+
}
|
|
487
|
+
if ((0, _orkestrel_contract.isArray)(value)) return value.map((entry) => normalizeValue(workspace, entry));
|
|
488
|
+
if ((0, _orkestrel_contract.isRecord)(value)) {
|
|
489
|
+
const record = {};
|
|
490
|
+
for (const key of Object.keys(value).sort()) record[key] = normalizeValue(workspace, value[key]);
|
|
491
|
+
return record;
|
|
492
|
+
}
|
|
493
|
+
return value;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Computes the canonical digest of one value as it stands in a target workspace.
|
|
497
|
+
*
|
|
498
|
+
* @remarks
|
|
499
|
+
* Digests the normalized value rather than the value itself, so one commit read at two absolute
|
|
500
|
+
* roots produces one digest and a digest carries no host path. The result is the leading 32 hex
|
|
501
|
+
* characters of a SHA-256 over the normalized JSON.
|
|
502
|
+
*
|
|
503
|
+
* @param workspace - The target workspace root the value's paths are read against
|
|
504
|
+
* @param value - The value to digest
|
|
505
|
+
* @returns 32 lowercase hex characters
|
|
506
|
+
*
|
|
507
|
+
* @example
|
|
508
|
+
* ```ts
|
|
509
|
+
* computeDigest('/srv/checkout', { strict: true }).length // 32
|
|
510
|
+
* ```
|
|
511
|
+
*/
|
|
512
|
+
function computeDigest(workspace, value) {
|
|
513
|
+
return (0, node_crypto.createHash)("sha256").update(JSON.stringify(normalizeValue(workspace, value))).digest("hex").slice(0, 32);
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Records the listeners one emitter carries for a set of events.
|
|
517
|
+
*
|
|
518
|
+
* @param emitter - The emitter to read
|
|
519
|
+
* @param events - The event names to record
|
|
520
|
+
* @returns The listeners each named event carries at the moment of the call
|
|
521
|
+
*
|
|
522
|
+
* @example
|
|
523
|
+
* ```ts
|
|
524
|
+
* const capture = captureListeners(process, ['SIGTERM'])
|
|
525
|
+
* capture.get('SIGTERM')?.length === process.listenerCount('SIGTERM') // true
|
|
526
|
+
* ```
|
|
527
|
+
*/
|
|
528
|
+
function captureListeners(emitter, events) {
|
|
529
|
+
const capture = /* @__PURE__ */ new Map();
|
|
530
|
+
for (const event of events) capture.set(event, emitter.listeners(event));
|
|
531
|
+
return capture;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Removes every listener one emitter gained for the captured events since its capture.
|
|
535
|
+
*
|
|
536
|
+
* @remarks
|
|
537
|
+
* Compares identity rather than name, so a listener whose name a dependency changes between
|
|
538
|
+
* releases is still recognized and a listener that merely shares a name is still left alone. A
|
|
539
|
+
* listener the capture already held survives, and a listener the capture held that has since been
|
|
540
|
+
* removed is not restored.
|
|
541
|
+
*
|
|
542
|
+
* A gain is not evidence of who caused it, so this reads every gain as the callee's. Call it only
|
|
543
|
+
* across a window nothing else can attach in — a synchronous call, or a call whose handlers land
|
|
544
|
+
* before its first await and that is released as it returns — because a listener anyone else
|
|
545
|
+
* attaches inside that window is removed with the rest. Where a caller cannot promise that window,
|
|
546
|
+
* hold each handler as a field and remove it by reference instead of capturing at all.
|
|
547
|
+
*
|
|
548
|
+
* @param emitter - The emitter to release
|
|
549
|
+
* @param capture - The listeners the emitter carried at capture time
|
|
550
|
+
* @returns Nothing
|
|
551
|
+
*
|
|
552
|
+
* @example
|
|
553
|
+
* ```ts
|
|
554
|
+
* const capture = captureListeners(process, ['SIGTERM'])
|
|
555
|
+
* process.on('SIGTERM', () => {})
|
|
556
|
+
* releaseListeners(process, capture)
|
|
557
|
+
* process.listenerCount('SIGTERM') === capture.get('SIGTERM')?.length // true
|
|
558
|
+
* ```
|
|
559
|
+
*/
|
|
560
|
+
function releaseListeners(emitter, capture) {
|
|
561
|
+
for (const [event, captured] of capture) for (const listener of emitter.listeners(event)) {
|
|
562
|
+
if (captured.includes(listener)) continue;
|
|
563
|
+
emitter.removeListener(event, listener);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
//#endregion
|
|
567
|
+
//#region src/server/Overlay.ts
|
|
568
|
+
/**
|
|
569
|
+
* Holds the candidate drafts one inspection substitutes for the files a tool would read from disk.
|
|
570
|
+
*
|
|
571
|
+
* @remarks
|
|
572
|
+
* One overlay belongs to one inspection: the inspection creates it, records every candidate it
|
|
573
|
+
* carries, and clears it when the inspection ends. Each stage adapts the same set to the host its
|
|
574
|
+
* own tool expects rather than sharing one filesystem across tools. A fresh identity per instance
|
|
575
|
+
* is what makes a resident tool re-read a path this overlay holds, so two inspections that supply
|
|
576
|
+
* different text for one path never share a cached answer.
|
|
577
|
+
*
|
|
578
|
+
* @example
|
|
579
|
+
* ```ts
|
|
580
|
+
* const overlay = new Overlay()
|
|
581
|
+
* overlay.set('/srv/checkout/src/core/greeting.ts', "export const GREETING = 'hi'\n")
|
|
582
|
+
* console.log(overlay.text('/srv/checkout/src/core/greeting.ts'))
|
|
583
|
+
* overlay.clear()
|
|
584
|
+
* ```
|
|
585
|
+
*/
|
|
586
|
+
var Overlay = class {
|
|
587
|
+
#revision = (0, node_crypto.randomUUID)();
|
|
588
|
+
#candidates = /* @__PURE__ */ new Map();
|
|
589
|
+
get revision() {
|
|
590
|
+
return this.#revision;
|
|
591
|
+
}
|
|
592
|
+
get paths() {
|
|
593
|
+
return [...this.#candidates.keys()];
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Records one candidate's text against the absolute path it stands in for.
|
|
597
|
+
*
|
|
598
|
+
* @param path - The absolute path the candidate replaces
|
|
599
|
+
* @param text - The candidate's full contents
|
|
600
|
+
* @returns Nothing
|
|
601
|
+
*/
|
|
602
|
+
set(path, text) {
|
|
603
|
+
this.#candidates.set(normalizePath(path), text);
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Reads the candidate text recorded for one absolute path.
|
|
607
|
+
*
|
|
608
|
+
* @param path - The absolute path to read
|
|
609
|
+
* @returns The recorded text, or `undefined` when this overlay holds no candidate there
|
|
610
|
+
*/
|
|
611
|
+
text(path) {
|
|
612
|
+
return this.#candidates.get(normalizePath(path));
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Checks whether a candidate sits beneath one directory.
|
|
616
|
+
*
|
|
617
|
+
* @remarks
|
|
618
|
+
* The answer is derived from the paths the overlay holds rather than stored, so it stops being
|
|
619
|
+
* true exactly when the inspection that declared those candidates clears them. Both sides pass
|
|
620
|
+
* through `normalizePath` first, because a tool that normalizes its own paths asks about a
|
|
621
|
+
* directory in a spelling the recorded path may not share.
|
|
622
|
+
*
|
|
623
|
+
* @param directory - The absolute directory path to check
|
|
624
|
+
* @returns True if a candidate path sits beneath the directory; false otherwise
|
|
625
|
+
*/
|
|
626
|
+
covers(directory) {
|
|
627
|
+
const base = `${normalizePath(directory).replace(/\/+$/, "")}/`;
|
|
628
|
+
for (const path of this.#candidates.keys()) if (path.startsWith(base)) return true;
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Releases every candidate.
|
|
633
|
+
*
|
|
634
|
+
* @returns Nothing
|
|
635
|
+
*/
|
|
636
|
+
clear() {
|
|
637
|
+
this.#candidates.clear();
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
//#endregion
|
|
641
|
+
//#region package.json
|
|
642
|
+
var version = "0.0.1";
|
|
643
|
+
var peerDependencies = {
|
|
644
|
+
"oxlint": "^1.77.0",
|
|
645
|
+
"typescript": "^6.0.0",
|
|
646
|
+
"vitest": "^4.1.0"
|
|
647
|
+
};
|
|
648
|
+
//#endregion
|
|
649
|
+
//#region src/server/stages/LintStage.ts
|
|
650
|
+
/**
|
|
651
|
+
* Inspects virtual documents through one resident Oxlint language server.
|
|
652
|
+
*
|
|
653
|
+
* @remarks
|
|
654
|
+
* Construction starts the target workspace's Oxlint binary with its Language Server Protocol
|
|
655
|
+
* mode. Each inspection opens the supplied text by URI, waits for published diagnostics, and
|
|
656
|
+
* closes the document without writing it to disk.
|
|
657
|
+
*
|
|
658
|
+
* Teardown is bounded at both ends. Warming waits for the server's `initialize` response and the
|
|
659
|
+
* ending waits for its `shutdown` reply, and the protocol leaves both answers to the server, so
|
|
660
|
+
* `destroy` bounds each exchange and takes the ending back with a signal when the bound passes.
|
|
661
|
+
*
|
|
662
|
+
* The URI is the path the candidate declared, so the candidate receives exactly the rule set the
|
|
663
|
+
* workspace's own lint gate applies to that path, including an override anchored to one directory,
|
|
664
|
+
* one file name, or one exact file. A path the workspace's version-control ignore files exclude is
|
|
665
|
+
* a path that gate never lints, and this stage reports the same nothing for it. Reuse of one
|
|
666
|
+
* declared path across inspections is what the caller's own serialization protects: the stage
|
|
667
|
+
* refuses a second inspection of a path it already has open rather than answering either one with
|
|
668
|
+
* the other's diagnostics.
|
|
669
|
+
*
|
|
670
|
+
* @example
|
|
671
|
+
* ```ts
|
|
672
|
+
* const stage = new LintStage('/srv/checkout')
|
|
673
|
+
* const check = await stage.inspect(subject)
|
|
674
|
+
* await stage.destroy()
|
|
675
|
+
* ```
|
|
676
|
+
*/
|
|
677
|
+
var LintStage = class {
|
|
678
|
+
#workspace;
|
|
679
|
+
#deadline = 2e3;
|
|
680
|
+
#warmth;
|
|
681
|
+
#responses = /* @__PURE__ */ new Map();
|
|
682
|
+
#failures = /* @__PURE__ */ new Map();
|
|
683
|
+
#documents = /* @__PURE__ */ new Map();
|
|
684
|
+
#publishes = /* @__PURE__ */ new Map();
|
|
685
|
+
#refusals = /* @__PURE__ */ new Map();
|
|
686
|
+
#child;
|
|
687
|
+
#buffer = Buffer.alloc(0);
|
|
688
|
+
#sequence = 0;
|
|
689
|
+
#closing;
|
|
690
|
+
#progress = 0;
|
|
691
|
+
#destroyed = false;
|
|
692
|
+
/**
|
|
693
|
+
* Starts warming the target workspace's Oxlint language server.
|
|
694
|
+
*
|
|
695
|
+
* @param workspace - The target workspace root. Default: the current working directory
|
|
696
|
+
*/
|
|
697
|
+
constructor(workspace = process.cwd()) {
|
|
698
|
+
this.#workspace = workspace;
|
|
699
|
+
this.#warmth = this.#warm();
|
|
700
|
+
this.#warmth.catch(() => {});
|
|
701
|
+
}
|
|
702
|
+
get stage() {
|
|
703
|
+
return "lint";
|
|
704
|
+
}
|
|
705
|
+
get progress() {
|
|
706
|
+
return this.#progress;
|
|
707
|
+
}
|
|
708
|
+
async inspect(subject) {
|
|
709
|
+
if (this.#destroyed) throw (0, _src_core.createDestroyedError)("lint stage");
|
|
710
|
+
const started = performance.now();
|
|
711
|
+
await this.#warmth;
|
|
712
|
+
if (this.#destroyed) throw (0, _src_core.createDestroyedError)("lint stage");
|
|
713
|
+
const issues = [];
|
|
714
|
+
for (const draft of [...subject.files, subject.test]) issues.push(...await this.#document(draft));
|
|
715
|
+
return {
|
|
716
|
+
stage: this.stage,
|
|
717
|
+
elapsed: Math.round(performance.now() - started),
|
|
718
|
+
issues
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
destroy() {
|
|
722
|
+
if (this.#closing !== void 0) return this.#closing;
|
|
723
|
+
this.#destroyed = true;
|
|
724
|
+
this.#closing = this.#destroy();
|
|
725
|
+
return this.#closing;
|
|
726
|
+
}
|
|
727
|
+
async #destroy() {
|
|
728
|
+
const child = await this.#warmed();
|
|
729
|
+
this.#fail((0, _src_core.createDestroyedError)("lint stage"));
|
|
730
|
+
if (child === void 0 || this.#ending !== void 0) return;
|
|
731
|
+
const released = new Promise((resolve) => {
|
|
732
|
+
child.once("exit", () => resolve());
|
|
733
|
+
child.once("close", () => resolve());
|
|
734
|
+
});
|
|
735
|
+
await this.#release(child, released);
|
|
736
|
+
}
|
|
737
|
+
async #warmed() {
|
|
738
|
+
const timeout = (0, _orkestrel_timeout.createTimeout)({ ms: this.#deadline });
|
|
739
|
+
timeout.start();
|
|
740
|
+
try {
|
|
741
|
+
return await Promise.race([this.#warmth, this.#expiry(timeout)]);
|
|
742
|
+
} catch {
|
|
743
|
+
return this.#child;
|
|
744
|
+
} finally {
|
|
745
|
+
timeout.clear();
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
async #release(child, released) {
|
|
749
|
+
const timeout = (0, _orkestrel_timeout.createTimeout)({ ms: this.#deadline });
|
|
750
|
+
timeout.start();
|
|
751
|
+
try {
|
|
752
|
+
await Promise.race([this.#retire(child).then(() => released), this.#expiry(timeout)]);
|
|
753
|
+
} catch {
|
|
754
|
+
child.kill("SIGKILL");
|
|
755
|
+
await released;
|
|
756
|
+
} finally {
|
|
757
|
+
timeout.clear();
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
async #retire(child) {
|
|
761
|
+
try {
|
|
762
|
+
await this.#request("shutdown", void 0);
|
|
763
|
+
this.#notify("exit", void 0);
|
|
764
|
+
} catch {
|
|
765
|
+
child.kill("SIGKILL");
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
#expiry(timeout) {
|
|
769
|
+
return new Promise((_resolve, reject) => {
|
|
770
|
+
timeout.signal.addEventListener("abort", () => reject(new _src_core.ProbeError(`The Oxlint language server did not exit within ${timeout.ms} ms`, {
|
|
771
|
+
origin: "instrument",
|
|
772
|
+
code: "deadline",
|
|
773
|
+
context: {
|
|
774
|
+
stage: this.stage,
|
|
775
|
+
deadline: timeout.ms
|
|
776
|
+
}
|
|
777
|
+
})), { once: true });
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
async #warm() {
|
|
781
|
+
const binary = resolveWorkspaceBinary(this.#workspace, "oxlint");
|
|
782
|
+
const child = (0, node_child_process.spawn)(process.execPath, [binary, "--lsp"], {
|
|
783
|
+
cwd: this.#workspace,
|
|
784
|
+
stdio: "pipe"
|
|
785
|
+
});
|
|
786
|
+
this.#child = child;
|
|
787
|
+
child.stderr.resume();
|
|
788
|
+
child.stdout.on("data", (chunk) => this.#read(chunk));
|
|
789
|
+
child.stdin.on("error", (error) => this.#fail(error));
|
|
790
|
+
child.on("error", (error) => this.#fail(error));
|
|
791
|
+
child.on("exit", (code, signal) => this.#exit(code, signal));
|
|
792
|
+
await this.#request("initialize", {
|
|
793
|
+
processId: process.pid,
|
|
794
|
+
rootUri: (0, node_url.pathToFileURL)(this.#workspace).href,
|
|
795
|
+
capabilities: {},
|
|
796
|
+
workspaceFolders: [{
|
|
797
|
+
uri: (0, node_url.pathToFileURL)(this.#workspace).href,
|
|
798
|
+
name: "workspace"
|
|
799
|
+
}]
|
|
800
|
+
});
|
|
801
|
+
this.#notify("initialized", {});
|
|
802
|
+
return child;
|
|
803
|
+
}
|
|
804
|
+
#document(draft) {
|
|
805
|
+
const uri = (0, node_url.pathToFileURL)(resolveWorkspaceFile(this.#workspace, draft.path)).href;
|
|
806
|
+
if (this.#publishes.has(uri)) return Promise.reject(new _src_core.ProbeError(`The lint stage is already inspecting ${draft.path}`, {
|
|
807
|
+
origin: "claimant",
|
|
808
|
+
code: "refused",
|
|
809
|
+
context: {
|
|
810
|
+
stage: this.stage,
|
|
811
|
+
path: draft.path
|
|
812
|
+
}
|
|
813
|
+
}));
|
|
814
|
+
const inspected = new Promise((resolve, reject) => {
|
|
815
|
+
this.#documents.set(uri, normalizePath(draft.path));
|
|
816
|
+
this.#publishes.set(uri, resolve);
|
|
817
|
+
this.#refusals.set(uri, reject);
|
|
818
|
+
}).finally(() => this.#close(uri));
|
|
819
|
+
try {
|
|
820
|
+
this.#notify("textDocument/didOpen", { textDocument: {
|
|
821
|
+
uri,
|
|
822
|
+
languageId: inferDocumentLanguage(draft.path),
|
|
823
|
+
version: this.#sequence,
|
|
824
|
+
text: draft.text
|
|
825
|
+
} });
|
|
826
|
+
this.#progress += 1;
|
|
827
|
+
} catch (error) {
|
|
828
|
+
this.#refusals.get(uri)?.(this.#fault(describeUnknown(error), error, draft.path));
|
|
829
|
+
}
|
|
830
|
+
return inspected;
|
|
831
|
+
}
|
|
832
|
+
get #ending() {
|
|
833
|
+
const child = this.#child;
|
|
834
|
+
if (child === void 0) return void 0;
|
|
835
|
+
if (child.exitCode === null && child.signalCode === null) return void 0;
|
|
836
|
+
return this.#describe(child.exitCode, child.signalCode);
|
|
837
|
+
}
|
|
838
|
+
#describe(code, signal) {
|
|
839
|
+
return signal === null ? `code ${code}` : `signal ${signal}`;
|
|
840
|
+
}
|
|
841
|
+
#fault(message, cause, path) {
|
|
842
|
+
return new _src_core.ProbeError(message, {
|
|
843
|
+
origin: "instrument",
|
|
844
|
+
code: "malformed",
|
|
845
|
+
context: {
|
|
846
|
+
stage: this.stage,
|
|
847
|
+
...path === void 0 ? {} : { path }
|
|
848
|
+
},
|
|
849
|
+
...cause === void 0 ? {} : { cause }
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
get #reachable() {
|
|
853
|
+
return this.#ending === void 0 && this.#child?.stdin.writable === true;
|
|
854
|
+
}
|
|
855
|
+
#close(uri) {
|
|
856
|
+
this.#documents.delete(uri);
|
|
857
|
+
this.#publishes.delete(uri);
|
|
858
|
+
this.#refusals.delete(uri);
|
|
859
|
+
if (this.#destroyed || !this.#reachable) return;
|
|
860
|
+
this.#notify("textDocument/didClose", { textDocument: { uri } });
|
|
861
|
+
}
|
|
862
|
+
#request(method, params) {
|
|
863
|
+
this.#sequence += 1;
|
|
864
|
+
const id = this.#sequence;
|
|
865
|
+
const response = new Promise((resolve, reject) => {
|
|
866
|
+
this.#responses.set(id, resolve);
|
|
867
|
+
this.#failures.set(id, reject);
|
|
868
|
+
});
|
|
869
|
+
try {
|
|
870
|
+
this.#send({
|
|
871
|
+
jsonrpc: "2.0",
|
|
872
|
+
id,
|
|
873
|
+
method,
|
|
874
|
+
params
|
|
875
|
+
});
|
|
876
|
+
} catch (error) {
|
|
877
|
+
const reject = this.#failures.get(id);
|
|
878
|
+
this.#responses.delete(id);
|
|
879
|
+
this.#failures.delete(id);
|
|
880
|
+
reject?.(this.#fault(describeUnknown(error), error));
|
|
881
|
+
}
|
|
882
|
+
return response;
|
|
883
|
+
}
|
|
884
|
+
#notify(method, params) {
|
|
885
|
+
this.#sequence += 1;
|
|
886
|
+
this.#send({
|
|
887
|
+
jsonrpc: "2.0",
|
|
888
|
+
method,
|
|
889
|
+
params
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
#send(message) {
|
|
893
|
+
const child = this.#child;
|
|
894
|
+
if (this.#ending !== void 0) throw this.#fault(`The Oxlint language server exited with ${this.#ending}`);
|
|
895
|
+
if (child === void 0) throw this.#fault("The Oxlint language server is not running");
|
|
896
|
+
if (!child.stdin.writable) throw this.#fault("The Oxlint language server closed its input");
|
|
897
|
+
const content = JSON.stringify(message);
|
|
898
|
+
const header = `Content-Length: ${Buffer.byteLength(content)}\r\n\r\n`;
|
|
899
|
+
child.stdin.write(header + content);
|
|
900
|
+
}
|
|
901
|
+
#read(chunk) {
|
|
902
|
+
this.#buffer = Buffer.concat([this.#buffer, chunk]);
|
|
903
|
+
while (this.#frame());
|
|
904
|
+
}
|
|
905
|
+
#frame() {
|
|
906
|
+
const boundary = this.#buffer.indexOf("\r\n\r\n");
|
|
907
|
+
if (boundary < 0) return false;
|
|
908
|
+
const length = parseContentLength(this.#buffer.subarray(0, boundary).toString("ascii"));
|
|
909
|
+
if (length === void 0) {
|
|
910
|
+
this.#fail(this.#fault("Oxlint sent an invalid JSON-RPC frame header"));
|
|
911
|
+
return false;
|
|
912
|
+
}
|
|
913
|
+
const start = boundary + 4;
|
|
914
|
+
const end = start + length;
|
|
915
|
+
if (this.#buffer.length < end) return false;
|
|
916
|
+
const content = this.#buffer.subarray(start, end).toString("utf8");
|
|
917
|
+
this.#buffer = this.#buffer.subarray(end);
|
|
918
|
+
try {
|
|
919
|
+
const message = JSON.parse(content);
|
|
920
|
+
this.#receive(message);
|
|
921
|
+
} catch (error) {
|
|
922
|
+
this.#fail(this.#fault(`Oxlint sent invalid JSON: ${describeUnknown(error)}`, error));
|
|
923
|
+
}
|
|
924
|
+
return true;
|
|
925
|
+
}
|
|
926
|
+
#receive(message) {
|
|
927
|
+
if (typeof message !== "object" || message === null) return;
|
|
928
|
+
if ("id" in message && typeof message.id === "number") {
|
|
929
|
+
const id = message.id;
|
|
930
|
+
const resolve = this.#responses.get(id);
|
|
931
|
+
const reject = this.#failures.get(id);
|
|
932
|
+
this.#responses.delete(id);
|
|
933
|
+
this.#failures.delete(id);
|
|
934
|
+
if ("error" in message && message.error !== void 0) reject?.(this.#fault(describeUnknown(message.error)));
|
|
935
|
+
else resolve?.("result" in message ? message.result : void 0);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
if (!("method" in message) || message.method !== "textDocument/publishDiagnostics") return;
|
|
939
|
+
if (!("params" in message) || typeof message.params !== "object" || message.params === null) return;
|
|
940
|
+
const params = message.params;
|
|
941
|
+
if (!("uri" in params) || typeof params.uri !== "string") return;
|
|
942
|
+
if (!("diagnostics" in params) || !Array.isArray(params.diagnostics)) return;
|
|
943
|
+
const publish = this.#publishes.get(params.uri);
|
|
944
|
+
if (publish === void 0) return;
|
|
945
|
+
publish(this.#issues(params.uri, params.diagnostics));
|
|
946
|
+
}
|
|
947
|
+
#issues(uri, diagnostics) {
|
|
948
|
+
const path = this.#documents.get(uri);
|
|
949
|
+
if (path === void 0) return [];
|
|
950
|
+
const issues = [];
|
|
951
|
+
for (const diagnostic of diagnostics) {
|
|
952
|
+
if (typeof diagnostic !== "object" || diagnostic === null) continue;
|
|
953
|
+
if (!("message" in diagnostic) || typeof diagnostic.message !== "string") continue;
|
|
954
|
+
if (!("range" in diagnostic) || typeof diagnostic.range !== "object" || diagnostic.range === null) {
|
|
955
|
+
issues.push({
|
|
956
|
+
origin: "claimant",
|
|
957
|
+
path,
|
|
958
|
+
message: diagnostic.message
|
|
959
|
+
});
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
const range = diagnostic.range;
|
|
963
|
+
if (!("start" in range) || typeof range.start !== "object" || range.start === null) {
|
|
964
|
+
issues.push({
|
|
965
|
+
origin: "claimant",
|
|
966
|
+
path,
|
|
967
|
+
message: diagnostic.message
|
|
968
|
+
});
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
const start = range.start;
|
|
972
|
+
if (!("line" in start) || typeof start.line !== "number") {
|
|
973
|
+
issues.push({
|
|
974
|
+
origin: "claimant",
|
|
975
|
+
path,
|
|
976
|
+
message: diagnostic.message
|
|
977
|
+
});
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
issues.push({
|
|
981
|
+
origin: "claimant",
|
|
982
|
+
path,
|
|
983
|
+
message: diagnostic.message,
|
|
984
|
+
line: start.line + 1
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
return issues;
|
|
988
|
+
}
|
|
989
|
+
#fail(error) {
|
|
990
|
+
for (const reject of this.#failures.values()) reject(error);
|
|
991
|
+
for (const reject of this.#refusals.values()) reject(error);
|
|
992
|
+
this.#responses.clear();
|
|
993
|
+
this.#failures.clear();
|
|
994
|
+
this.#publishes.clear();
|
|
995
|
+
this.#refusals.clear();
|
|
996
|
+
}
|
|
997
|
+
#exit(code, signal) {
|
|
998
|
+
this.#fail(this.#fault(`The Oxlint language server exited with ${this.#describe(code, signal)}`));
|
|
999
|
+
}
|
|
1000
|
+
};
|
|
1001
|
+
//#endregion
|
|
1002
|
+
//#region src/server/stages/RuntimeStage.ts
|
|
1003
|
+
/**
|
|
1004
|
+
* Inspects tests through one resident Vitest service from the target workspace.
|
|
1005
|
+
*
|
|
1006
|
+
* @remarks
|
|
1007
|
+
* Construction starts Vitest with the threads pool and instruments each inline or function-declared
|
|
1008
|
+
* project with a Vite plugin that reads the active inspection's candidate overlay. A selected
|
|
1009
|
+
* string-declared project reports a workspace issue because its configuration carries no runtime
|
|
1010
|
+
* overlay plugin. Every inspection writes one fresh sibling specification, invalidates each
|
|
1011
|
+
* workspace module whose disk content or candidate revision changed, runs that specification,
|
|
1012
|
+
* evicts its result, and deletes the file. Clearing the overlay makes the next snapshot differ from
|
|
1013
|
+
* the candidate revision, so the next inspection invalidates that module and reads disk again.
|
|
1014
|
+
*
|
|
1015
|
+
* Vite retains one unresolved URL for every specification path, so the stage replaces its whole
|
|
1016
|
+
* Vitest service after 64 specifications rather than deleting from each map that service owns. Any
|
|
1017
|
+
* bound holds the retention flat, because the replacement releases everything the instance held,
|
|
1018
|
+
* and 64 is the value chosen. The inspection that crosses the bound pays the replacement
|
|
1019
|
+
* synchronously: it closes the resident service and warms a new one before it runs. On 2026-08-20,
|
|
1020
|
+
* over this package's own workspace on the host `guides/probe.md` § Cost names, two runs of 66
|
|
1021
|
+
* inspections put that one at 480 ms and 269 ms against median inspections of 156 ms and 155 ms, so
|
|
1022
|
+
* budget one call in 64 at 110 ms to 330 ms above the rest.
|
|
1023
|
+
*
|
|
1024
|
+
* Every issue this stage emits names its party, and the stage emits an issue for every party
|
|
1025
|
+
* `Party` declares. An `origin: 'claimant'` issue is a failure Vitest reported about the candidate.
|
|
1026
|
+
* An `origin: 'workspace'` issue names the target tree: a project the root configuration declares
|
|
1027
|
+
* as a path string, into which the stage can install no overlay, and a specification path that
|
|
1028
|
+
* crosses a symbolic link, whose existing components this host cannot inspect, or whose directory
|
|
1029
|
+
* the target tree blocks probe from creating. An `origin: 'instrument'` issue names this package's
|
|
1030
|
+
* own machinery: a specification it could not write after its directory exists, or one it could
|
|
1031
|
+
* not run, evict, or delete, in each case for a reason the target tree does not own, and a run
|
|
1032
|
+
* that reported no test. An unmapped caller test and a test whose named project is absent are claimant failures with
|
|
1033
|
+
* `code: 'missing'`, thrown rather than reported, because a test that never ran is no evidence
|
|
1034
|
+
* about the candidate.
|
|
1035
|
+
*
|
|
1036
|
+
* The write path's physical-containment guarantee covers the claim inputs and the target tree as
|
|
1037
|
+
* inspected. A concurrent process that mutates a path component between the final inspection and
|
|
1038
|
+
* the write is outside that guarantee.
|
|
1039
|
+
*
|
|
1040
|
+
* @example
|
|
1041
|
+
* ```ts
|
|
1042
|
+
* const stage = new RuntimeStage('/srv/checkout')
|
|
1043
|
+
* const check = await stage.inspect(subject)
|
|
1044
|
+
* await stage.destroy()
|
|
1045
|
+
* ```
|
|
1046
|
+
*/
|
|
1047
|
+
var RuntimeStage = class {
|
|
1048
|
+
#workspace;
|
|
1049
|
+
#vitest;
|
|
1050
|
+
#overlay = new Overlay();
|
|
1051
|
+
#modules = /* @__PURE__ */ new Map();
|
|
1052
|
+
#revisions = /* @__PURE__ */ new Set();
|
|
1053
|
+
#specifications = 0;
|
|
1054
|
+
#closing;
|
|
1055
|
+
#progress = 0;
|
|
1056
|
+
#destroyed = false;
|
|
1057
|
+
/**
|
|
1058
|
+
* Starts warming the target workspace's Vitest service.
|
|
1059
|
+
*
|
|
1060
|
+
* @param workspace - The target workspace root. Default: the current working directory
|
|
1061
|
+
*/
|
|
1062
|
+
constructor(workspace = process.cwd()) {
|
|
1063
|
+
this.#workspace = workspace;
|
|
1064
|
+
const vitest = loadWorkspaceModule(this.#workspace, "vitest/node");
|
|
1065
|
+
this.#vitest = this.#warm(vitest.createVitest);
|
|
1066
|
+
this.#vitest.catch(() => {});
|
|
1067
|
+
}
|
|
1068
|
+
get stage() {
|
|
1069
|
+
return "runtime";
|
|
1070
|
+
}
|
|
1071
|
+
get progress() {
|
|
1072
|
+
return this.#progress;
|
|
1073
|
+
}
|
|
1074
|
+
async inspect(subject) {
|
|
1075
|
+
if (this.#destroyed) throw (0, _src_core.createDestroyedError)("runtime stage");
|
|
1076
|
+
const started = performance.now();
|
|
1077
|
+
const exitCode = process.exitCode;
|
|
1078
|
+
const vitest = await this.#runner();
|
|
1079
|
+
if (this.#destroyed) throw (0, _src_core.createDestroyedError)("runtime stage");
|
|
1080
|
+
const project = this.#project(vitest, subject.test.path);
|
|
1081
|
+
if ("message" in project) return {
|
|
1082
|
+
stage: this.stage,
|
|
1083
|
+
elapsed: Math.round(performance.now() - started),
|
|
1084
|
+
issues: [project]
|
|
1085
|
+
};
|
|
1086
|
+
const overlay = new Overlay();
|
|
1087
|
+
this.#overlay = overlay;
|
|
1088
|
+
try {
|
|
1089
|
+
for (const draft of subject.files) overlay.set(resolveWorkspaceFile(this.#workspace, draft.path), draft.text);
|
|
1090
|
+
this.#revalidate(vitest);
|
|
1091
|
+
const specification = this.#specification(subject.test);
|
|
1092
|
+
if (typeof specification !== "string") return {
|
|
1093
|
+
stage: this.stage,
|
|
1094
|
+
elapsed: Math.round(performance.now() - started),
|
|
1095
|
+
issues: [specification]
|
|
1096
|
+
};
|
|
1097
|
+
const file = specification;
|
|
1098
|
+
this.#specifications += 1;
|
|
1099
|
+
this.#revisions.add(file);
|
|
1100
|
+
let issues = [];
|
|
1101
|
+
let cleanup = [];
|
|
1102
|
+
const task = project.createSpecification(file, void 0, "threads");
|
|
1103
|
+
this.#progress += 1;
|
|
1104
|
+
try {
|
|
1105
|
+
try {
|
|
1106
|
+
const result = await vitest.runTestSpecifications([task], false);
|
|
1107
|
+
issues = this.#issues(result, file, subject.test.path);
|
|
1108
|
+
} catch (error) {
|
|
1109
|
+
if (error instanceof _src_core.ProbeError) throw error;
|
|
1110
|
+
throw new _src_core.ProbeError(`The runtime stage could not run the generated specification (${describeUnknown(error)})`, {
|
|
1111
|
+
origin: "instrument",
|
|
1112
|
+
code: "malformed",
|
|
1113
|
+
context: {
|
|
1114
|
+
stage: this.stage,
|
|
1115
|
+
path: subject.test.path
|
|
1116
|
+
},
|
|
1117
|
+
cause: error
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
} finally {
|
|
1121
|
+
this.#progress -= 1;
|
|
1122
|
+
process.exitCode = exitCode;
|
|
1123
|
+
this.#revisions.delete(file);
|
|
1124
|
+
cleanup = await this.#evict(vitest, file);
|
|
1125
|
+
try {
|
|
1126
|
+
const contained = resolveWorkspaceFile(this.#workspace, (0, node_path.relative)(this.#workspace, file), true);
|
|
1127
|
+
if ((0, node_fs.existsSync)(contained)) (0, node_fs.unlinkSync)(contained);
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
cleanup = [...cleanup, {
|
|
1130
|
+
origin: error instanceof _src_core.ProbeError && error.origin === "workspace" ? "workspace" : "instrument",
|
|
1131
|
+
path: relativeWorkspaceFile(this.#workspace, file),
|
|
1132
|
+
message: `The runtime stage could not delete the generated specification (${describeUnknown(error)})`
|
|
1133
|
+
}];
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
return {
|
|
1137
|
+
stage: this.stage,
|
|
1138
|
+
elapsed: Math.round(performance.now() - started),
|
|
1139
|
+
issues: [...issues, ...cleanup]
|
|
1140
|
+
};
|
|
1141
|
+
} finally {
|
|
1142
|
+
overlay.clear();
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
destroy() {
|
|
1146
|
+
if (this.#closing !== void 0) return this.#closing;
|
|
1147
|
+
this.#destroyed = true;
|
|
1148
|
+
this.#closing = this.#destroy();
|
|
1149
|
+
return this.#closing;
|
|
1150
|
+
}
|
|
1151
|
+
async #destroy() {
|
|
1152
|
+
try {
|
|
1153
|
+
for (const file of this.#revisions) try {
|
|
1154
|
+
const contained = resolveWorkspaceFile(this.#workspace, (0, node_path.relative)(this.#workspace, file), true);
|
|
1155
|
+
if ((0, node_fs.existsSync)(contained)) (0, node_fs.unlinkSync)(contained);
|
|
1156
|
+
} catch {}
|
|
1157
|
+
this.#revisions.clear();
|
|
1158
|
+
const vitest = await this.#vitest.catch(() => void 0);
|
|
1159
|
+
if (vitest !== void 0) {
|
|
1160
|
+
vitest.cancelCurrentRun("keyboard-input").catch(() => {});
|
|
1161
|
+
await vitest.close();
|
|
1162
|
+
}
|
|
1163
|
+
} finally {
|
|
1164
|
+
this.#modules.clear();
|
|
1165
|
+
this.#overlay.clear();
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
async #warm(create) {
|
|
1169
|
+
this.#sweep();
|
|
1170
|
+
const output = new node_stream.PassThrough();
|
|
1171
|
+
output.resume();
|
|
1172
|
+
const signals = captureListeners(process, ["SIGINT", "SIGTERM"]);
|
|
1173
|
+
try {
|
|
1174
|
+
return create("test", {
|
|
1175
|
+
root: this.#workspace,
|
|
1176
|
+
config: resolveWorkspaceFile(this.#workspace, "vite.config.ts"),
|
|
1177
|
+
watch: false,
|
|
1178
|
+
run: true,
|
|
1179
|
+
pool: "threads",
|
|
1180
|
+
reporters: [{
|
|
1181
|
+
onInit() {},
|
|
1182
|
+
onTestRunEnd() {}
|
|
1183
|
+
}]
|
|
1184
|
+
}, { plugins: [{
|
|
1185
|
+
name: "orkestrel-project-instrumentation",
|
|
1186
|
+
enforce: "post",
|
|
1187
|
+
config: this.#configure.bind(this)
|
|
1188
|
+
}] }, {
|
|
1189
|
+
stdout: output,
|
|
1190
|
+
stderr: process.stderr
|
|
1191
|
+
});
|
|
1192
|
+
} finally {
|
|
1193
|
+
releaseListeners(process, signals);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
#configure(config) {
|
|
1197
|
+
const test = config.test;
|
|
1198
|
+
if (test?.projects === void 0) return;
|
|
1199
|
+
test.projects = test.projects.map((project) => this.#augment(project));
|
|
1200
|
+
}
|
|
1201
|
+
#augment(project) {
|
|
1202
|
+
if (typeof project === "string") return project;
|
|
1203
|
+
if (typeof project === "function") return this.#wrap(project);
|
|
1204
|
+
return Promise.resolve(project).then(this.#instrument.bind(this));
|
|
1205
|
+
}
|
|
1206
|
+
#wrap(project) {
|
|
1207
|
+
return async (environment) => this.#instrument(await project(environment));
|
|
1208
|
+
}
|
|
1209
|
+
#instrument(config) {
|
|
1210
|
+
return {
|
|
1211
|
+
...config,
|
|
1212
|
+
plugins: [...config.plugins ?? [], {
|
|
1213
|
+
name: "orkestrel-runtime-overlay",
|
|
1214
|
+
enforce: "pre",
|
|
1215
|
+
resolveId: this.#resolve.bind(this),
|
|
1216
|
+
load: this.#load.bind(this)
|
|
1217
|
+
}]
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
#resolve(id, importer) {
|
|
1221
|
+
const separator = id.lastIndexOf("?");
|
|
1222
|
+
const specifier = separator === -1 ? id : id.slice(0, separator);
|
|
1223
|
+
const suffix = separator === -1 ? "" : id.slice(separator);
|
|
1224
|
+
if (!specifier.startsWith(".") && !(0, node_path.isAbsolute)(specifier)) return void 0;
|
|
1225
|
+
if (importer === void 0 && !(0, node_path.isAbsolute)(specifier)) return void 0;
|
|
1226
|
+
const imported = (0, node_path.isAbsolute)(specifier) ? specifier : (0, node_path.resolve)((0, node_path.dirname)(importer ?? this.#workspace), specifier);
|
|
1227
|
+
const candidates = [imported];
|
|
1228
|
+
const extension = (0, node_path.extname)(imported);
|
|
1229
|
+
if (extension === ".js") candidates.push(`${imported.slice(0, -extension.length)}.ts`);
|
|
1230
|
+
if (extension === "") candidates.push(`${imported}.ts`);
|
|
1231
|
+
for (const candidate of candidates) {
|
|
1232
|
+
const resolved = `${candidate}${suffix}`;
|
|
1233
|
+
if (this.#load(resolved) !== void 0) return resolved;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
#load(id) {
|
|
1237
|
+
const text = this.#overlay.text(id);
|
|
1238
|
+
if (text !== void 0) return text;
|
|
1239
|
+
const separator = id.lastIndexOf("?");
|
|
1240
|
+
if (separator === -1) return void 0;
|
|
1241
|
+
const query = id.slice(separator + 1);
|
|
1242
|
+
if (!query.startsWith("v=") || query.length === 2 || query.includes("&") || query.includes("#") || query.slice(2).includes("?")) return;
|
|
1243
|
+
return this.#overlay.text(id.slice(0, separator));
|
|
1244
|
+
}
|
|
1245
|
+
#specification(test) {
|
|
1246
|
+
let creating = false;
|
|
1247
|
+
const outcome = (0, _orkestrel_contract.attempt)(() => {
|
|
1248
|
+
const revision = `${process.pid}-${(0, node_crypto.randomUUID)()}`;
|
|
1249
|
+
const file = createRevisionFile(this.#workspace, test.path, revision);
|
|
1250
|
+
const target = (0, node_path.relative)(this.#workspace, file);
|
|
1251
|
+
resolveWorkspaceFile(this.#workspace, target, true);
|
|
1252
|
+
creating = true;
|
|
1253
|
+
(0, node_fs.mkdirSync)((0, node_path.dirname)(file), { recursive: true });
|
|
1254
|
+
creating = false;
|
|
1255
|
+
resolveWorkspaceFile(this.#workspace, target, true);
|
|
1256
|
+
(0, node_fs.writeFileSync)(file, (0, _src_core.formatSpecification)(test.text, revision), {
|
|
1257
|
+
encoding: "utf8",
|
|
1258
|
+
flag: "wx"
|
|
1259
|
+
});
|
|
1260
|
+
return file;
|
|
1261
|
+
});
|
|
1262
|
+
if (outcome.success) return outcome.value;
|
|
1263
|
+
if (outcome.error instanceof _src_core.ProbeError && outcome.error.origin === "claimant") throw new _src_core.ProbeError(outcome.error.message, {
|
|
1264
|
+
origin: outcome.error.origin,
|
|
1265
|
+
code: outcome.error.code,
|
|
1266
|
+
context: {
|
|
1267
|
+
...outcome.error.context,
|
|
1268
|
+
path: test.path
|
|
1269
|
+
},
|
|
1270
|
+
cause: outcome.error.cause
|
|
1271
|
+
});
|
|
1272
|
+
return {
|
|
1273
|
+
origin: outcome.error instanceof _src_core.ProbeError && outcome.error.origin === "workspace" || creating ? "workspace" : "instrument",
|
|
1274
|
+
path: test.path,
|
|
1275
|
+
message: `The runtime stage could not write the generated specification (${describeUnknown(outcome.error)})`
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
#project(vitest, path) {
|
|
1279
|
+
const name = inferTestProject((0, node_path.relative)(this.#workspace, (0, node_path.resolve)(this.#workspace, path)));
|
|
1280
|
+
if (name === void 0) throw new _src_core.ProbeError("The runtime stage found no configured Vitest project matching the test path", {
|
|
1281
|
+
origin: "claimant",
|
|
1282
|
+
code: "missing",
|
|
1283
|
+
context: {
|
|
1284
|
+
stage: this.stage,
|
|
1285
|
+
path
|
|
1286
|
+
}
|
|
1287
|
+
});
|
|
1288
|
+
const project = vitest.projects.find((candidate) => candidate.name === name);
|
|
1289
|
+
if (project === void 0) throw new _src_core.ProbeError(`The runtime stage found no configured Vitest project named ${name}`, {
|
|
1290
|
+
origin: "claimant",
|
|
1291
|
+
code: "missing",
|
|
1292
|
+
context: {
|
|
1293
|
+
stage: this.stage,
|
|
1294
|
+
path
|
|
1295
|
+
}
|
|
1296
|
+
});
|
|
1297
|
+
if (!project.vite.config.plugins.some((plugin) => plugin.name === "orkestrel-runtime-overlay")) return {
|
|
1298
|
+
origin: "workspace",
|
|
1299
|
+
path,
|
|
1300
|
+
message: `The runtime stage cannot instrument the string-declared Vitest project ${name} because its configuration carries no runtime overlay plugin`
|
|
1301
|
+
};
|
|
1302
|
+
return project;
|
|
1303
|
+
}
|
|
1304
|
+
async #evict(vitest, file) {
|
|
1305
|
+
try {
|
|
1306
|
+
const ids = [];
|
|
1307
|
+
for (const [id, task] of vitest.state.idMap) {
|
|
1308
|
+
const path = "filepath" in task ? task.filepath : task.file.filepath;
|
|
1309
|
+
if ((0, node_path.resolve)(path) === (0, node_path.resolve)(file)) ids.push(id);
|
|
1310
|
+
}
|
|
1311
|
+
for (const id of ids) vitest.state.idMap.delete(id);
|
|
1312
|
+
vitest.state.pathsSet.delete(file);
|
|
1313
|
+
vitest.clearSpecificationsCache(file);
|
|
1314
|
+
vitest.invalidateFile(file);
|
|
1315
|
+
const graphs = vitest.projects.flatMap((project) => Object.values(project.vite.environments).map((environment) => environment.moduleGraph));
|
|
1316
|
+
for (const graph of graphs) {
|
|
1317
|
+
const modules = graph.getModulesByFile(file);
|
|
1318
|
+
graph.onFileDelete(file);
|
|
1319
|
+
if (modules === void 0) continue;
|
|
1320
|
+
for (const module of modules) {
|
|
1321
|
+
for (const importer of module.importers) {
|
|
1322
|
+
importer.importedModules.delete(module);
|
|
1323
|
+
importer.acceptedHmrDeps.delete(module);
|
|
1324
|
+
}
|
|
1325
|
+
if (module.id !== null) graph.idToModuleMap.delete(module.id);
|
|
1326
|
+
graph.urlToModuleMap.delete(module.url);
|
|
1327
|
+
}
|
|
1328
|
+
graph.fileToModulesMap.delete(file);
|
|
1329
|
+
}
|
|
1330
|
+
vitest.watcher.onFileDelete(file);
|
|
1331
|
+
vitest.watcher.invalidates.delete(file);
|
|
1332
|
+
vitest.cache.results.removeFromCache(normalizePath((0, node_path.relative)(this.#workspace, file)));
|
|
1333
|
+
await vitest.cache.results.writeToCache();
|
|
1334
|
+
return [];
|
|
1335
|
+
} catch (error) {
|
|
1336
|
+
return [{
|
|
1337
|
+
origin: "instrument",
|
|
1338
|
+
path: relativeWorkspaceFile(this.#workspace, file),
|
|
1339
|
+
message: `The runtime stage could not evict the generated specification (${describeUnknown(error)})`
|
|
1340
|
+
}];
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
#runner() {
|
|
1344
|
+
if (this.#specifications < 64) return this.#vitest;
|
|
1345
|
+
this.#specifications = 0;
|
|
1346
|
+
this.#vitest = this.#replace(this.#vitest);
|
|
1347
|
+
this.#vitest.catch(() => {});
|
|
1348
|
+
return this.#vitest;
|
|
1349
|
+
}
|
|
1350
|
+
async #replace(current) {
|
|
1351
|
+
await (await current).close();
|
|
1352
|
+
if (this.#destroyed) throw (0, _src_core.createDestroyedError)("runtime stage");
|
|
1353
|
+
const runner = loadWorkspaceModule(this.#workspace, "vitest/node");
|
|
1354
|
+
return this.#warm(runner.createVitest);
|
|
1355
|
+
}
|
|
1356
|
+
#revalidate(vitest) {
|
|
1357
|
+
const modules = this.#snapshot();
|
|
1358
|
+
for (const [path, digest] of modules) {
|
|
1359
|
+
if (this.#modules.get(path) === digest) continue;
|
|
1360
|
+
this.#invalidate(vitest, path);
|
|
1361
|
+
}
|
|
1362
|
+
for (const path of this.#modules.keys()) {
|
|
1363
|
+
if (modules.has(path)) continue;
|
|
1364
|
+
this.#invalidate(vitest, path);
|
|
1365
|
+
}
|
|
1366
|
+
this.#modules.clear();
|
|
1367
|
+
for (const [path, digest] of modules) this.#modules.set(path, digest);
|
|
1368
|
+
}
|
|
1369
|
+
#invalidate(vitest, path) {
|
|
1370
|
+
vitest.invalidateFile(path);
|
|
1371
|
+
vitest.watcher.invalidates.add(path);
|
|
1372
|
+
}
|
|
1373
|
+
#snapshot() {
|
|
1374
|
+
const modules = /* @__PURE__ */ new Map();
|
|
1375
|
+
for (const path of this.#walk()) {
|
|
1376
|
+
if (!matchesWorkspaceModule(path)) continue;
|
|
1377
|
+
try {
|
|
1378
|
+
const digest = (0, node_crypto.createHash)("sha256").update((0, node_fs.readFileSync)(path)).digest("hex");
|
|
1379
|
+
modules.set(path, digest);
|
|
1380
|
+
} catch {}
|
|
1381
|
+
}
|
|
1382
|
+
for (const path of this.#overlay.paths) modules.set(path, `overlay:${this.#overlay.revision}`);
|
|
1383
|
+
return modules;
|
|
1384
|
+
}
|
|
1385
|
+
#sweep() {
|
|
1386
|
+
for (const path of this.#walk()) {
|
|
1387
|
+
const match = [...(0, node_path.basename)(path).matchAll(/\.probe-((\d+)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?=\.|$)/gu)].at(-1);
|
|
1388
|
+
const revision = match?.[1];
|
|
1389
|
+
const owner = match?.[2];
|
|
1390
|
+
if (revision === void 0 || owner === void 0) continue;
|
|
1391
|
+
if (this.#alive(Number.parseInt(owner, 10))) continue;
|
|
1392
|
+
if (!this.#owned(path, revision)) continue;
|
|
1393
|
+
try {
|
|
1394
|
+
(0, node_fs.unlinkSync)(resolveWorkspaceFile(this.#workspace, (0, node_path.relative)(this.#workspace, path), true));
|
|
1395
|
+
} catch {}
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
#owned(path, revision) {
|
|
1399
|
+
const outcome = (0, _orkestrel_contract.attempt)(() => (0, node_fs.readFileSync)(path, "utf8"));
|
|
1400
|
+
return outcome.success && (0, _src_core.matchesSpecification)(outcome.value, revision);
|
|
1401
|
+
}
|
|
1402
|
+
#alive(id) {
|
|
1403
|
+
if (!Number.isSafeInteger(id) || id <= 0) return true;
|
|
1404
|
+
try {
|
|
1405
|
+
process.kill(id, 0);
|
|
1406
|
+
return true;
|
|
1407
|
+
} catch (error) {
|
|
1408
|
+
return error instanceof Error && "code" in error && error.code === "EPERM";
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
*#walk() {
|
|
1412
|
+
const directories = [(0, node_path.resolve)(this.#workspace)];
|
|
1413
|
+
while (directories.length > 0) {
|
|
1414
|
+
const directory = directories.pop();
|
|
1415
|
+
if (directory === void 0) break;
|
|
1416
|
+
let entries = [];
|
|
1417
|
+
try {
|
|
1418
|
+
entries = (0, node_fs.readdirSync)(directory, { withFileTypes: true });
|
|
1419
|
+
} catch {}
|
|
1420
|
+
for (const entry of entries) {
|
|
1421
|
+
if (entry.name === ".git" || entry.name === "dist" || entry.name === "node_modules") continue;
|
|
1422
|
+
const path = (0, node_path.join)(directory, entry.name);
|
|
1423
|
+
if (entry.isDirectory()) {
|
|
1424
|
+
directories.push(path);
|
|
1425
|
+
continue;
|
|
1426
|
+
}
|
|
1427
|
+
if (entry.isFile()) yield path;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
#issues(result, file, original) {
|
|
1432
|
+
const specification = this.#real(file);
|
|
1433
|
+
const issues = [];
|
|
1434
|
+
for (const module of result.testModules) {
|
|
1435
|
+
const before = issues.length;
|
|
1436
|
+
for (const error of module.errors()) issues.push(this.#issue(error, specification, original));
|
|
1437
|
+
for (const test of module.children.allTests("failed")) {
|
|
1438
|
+
const errors = test.result().errors ?? [];
|
|
1439
|
+
for (const error of errors) issues.push(this.#issue(error, specification, original));
|
|
1440
|
+
}
|
|
1441
|
+
const state = module.state();
|
|
1442
|
+
if (state === "passed") {
|
|
1443
|
+
if (Array.from(module.children.allTests()).length === 0) issues.push({
|
|
1444
|
+
origin: "instrument",
|
|
1445
|
+
path: original,
|
|
1446
|
+
message: "Vitest ran no tests in the module"
|
|
1447
|
+
});
|
|
1448
|
+
for (const test of module.children.allTests("skipped")) issues.push({
|
|
1449
|
+
origin: "instrument",
|
|
1450
|
+
path: original,
|
|
1451
|
+
message: `Vitest did not run the test (${test.fullName})`
|
|
1452
|
+
});
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
if (state === "skipped") {
|
|
1456
|
+
issues.push({
|
|
1457
|
+
origin: "instrument",
|
|
1458
|
+
path: original,
|
|
1459
|
+
message: "Vitest ran no tests in the module"
|
|
1460
|
+
});
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
if (state === "failed") {
|
|
1464
|
+
if (issues.length === before) issues.push({
|
|
1465
|
+
origin: "claimant",
|
|
1466
|
+
path: original,
|
|
1467
|
+
message: "Vitest reported a failed test module"
|
|
1468
|
+
});
|
|
1469
|
+
continue;
|
|
1470
|
+
}
|
|
1471
|
+
if (state === "pending" || state === "queued") {
|
|
1472
|
+
issues.push({
|
|
1473
|
+
origin: "instrument",
|
|
1474
|
+
path: original,
|
|
1475
|
+
message: `Vitest did not finish the test module (${state})`
|
|
1476
|
+
});
|
|
1477
|
+
continue;
|
|
1478
|
+
}
|
|
1479
|
+
issues.push({
|
|
1480
|
+
origin: "instrument",
|
|
1481
|
+
path: original,
|
|
1482
|
+
message: `Vitest reported an unrecognized test module state (${state})`
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
for (const error of result.unhandledErrors) issues.push(this.#issue(error, specification, original));
|
|
1486
|
+
if (result.testModules.length === 0 && issues.length === 0) issues.push({
|
|
1487
|
+
origin: "instrument",
|
|
1488
|
+
path: original,
|
|
1489
|
+
message: "Vitest returned no test module"
|
|
1490
|
+
});
|
|
1491
|
+
return issues;
|
|
1492
|
+
}
|
|
1493
|
+
#real(path) {
|
|
1494
|
+
const outcome = (0, _orkestrel_contract.attempt)(() => (0, node_fs.realpathSync)(path));
|
|
1495
|
+
return outcome.success ? outcome.value : path;
|
|
1496
|
+
}
|
|
1497
|
+
#issue(error, specification, original) {
|
|
1498
|
+
const message = describeUnknown(error);
|
|
1499
|
+
if (typeof error !== "object" || error === null || !("stacks" in error)) return {
|
|
1500
|
+
origin: "claimant",
|
|
1501
|
+
path: original,
|
|
1502
|
+
message
|
|
1503
|
+
};
|
|
1504
|
+
const stacks = error.stacks;
|
|
1505
|
+
if (!Array.isArray(stacks)) return {
|
|
1506
|
+
origin: "claimant",
|
|
1507
|
+
path: original,
|
|
1508
|
+
message
|
|
1509
|
+
};
|
|
1510
|
+
for (const stack of stacks) {
|
|
1511
|
+
if (typeof stack !== "object" || stack === null) continue;
|
|
1512
|
+
if (!("file" in stack) || typeof stack.file !== "string") continue;
|
|
1513
|
+
const declared = stack.file.startsWith("file:") ? (0, node_url.fileURLToPath)(stack.file) : (0, node_path.isAbsolute)(stack.file) ? stack.file : (0, node_path.resolve)(this.#workspace, stack.file);
|
|
1514
|
+
const reported = this.#real(declared);
|
|
1515
|
+
const path = reported === specification ? original : relativeWorkspaceFile(this.#real(this.#workspace), reported);
|
|
1516
|
+
if (!("line" in stack) || typeof stack.line !== "number") return {
|
|
1517
|
+
origin: "claimant",
|
|
1518
|
+
path,
|
|
1519
|
+
message
|
|
1520
|
+
};
|
|
1521
|
+
return {
|
|
1522
|
+
origin: "claimant",
|
|
1523
|
+
path,
|
|
1524
|
+
message,
|
|
1525
|
+
line: stack.line
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
return {
|
|
1529
|
+
origin: "claimant",
|
|
1530
|
+
path: original,
|
|
1531
|
+
message
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
};
|
|
1535
|
+
//#endregion
|
|
1536
|
+
//#region src/server/stages/TypeStage.ts
|
|
1537
|
+
/**
|
|
1538
|
+
* Inspects TypeScript source through resident language services from the target workspace.
|
|
1539
|
+
*
|
|
1540
|
+
* @remarks
|
|
1541
|
+
* Construction starts loading the workspace's compiler and warming one service per project the
|
|
1542
|
+
* workspace declares. A candidate draft is checked against the project a call names, or
|
|
1543
|
+
* against its own scoped environment project when a call names none, while the test uses the root
|
|
1544
|
+
* project. Disk snapshots use their modification time as the service version so dependency edits
|
|
1545
|
+
* cannot leave a warm answer stale. Candidate text lives in an overlay the inspection owns, and
|
|
1546
|
+
* the language-service host answers existence, reads, and versions from it, so a candidate that
|
|
1547
|
+
* exists only as text is importable and a candidate that shadows a disk file is checked as the
|
|
1548
|
+
* text the case supplied. A project outside the declared set holds one recycled slot, so a caller
|
|
1549
|
+
* varying the project cannot grow the resident set. A language service checks one candidate
|
|
1550
|
+
* synchronously, so the stage hands the host's event loop back at each candidate boundary: a
|
|
1551
|
+
* caller's deadline is answered within one candidate's check rather than after the whole
|
|
1552
|
+
* inspection, and an inspection abandoned at that deadline stops at the next boundary.
|
|
1553
|
+
*
|
|
1554
|
+
* @example
|
|
1555
|
+
* ```ts
|
|
1556
|
+
* const stage = new TypeStage('/srv/checkout')
|
|
1557
|
+
* const check = await stage.inspect(subject, 'configs/src/tsconfig.core.json')
|
|
1558
|
+
* await stage.destroy()
|
|
1559
|
+
* ```
|
|
1560
|
+
*/
|
|
1561
|
+
var TypeStage = class {
|
|
1562
|
+
#workspace;
|
|
1563
|
+
#typescript;
|
|
1564
|
+
#services = /* @__PURE__ */ new Map();
|
|
1565
|
+
#resident = /* @__PURE__ */ new Set();
|
|
1566
|
+
#options = /* @__PURE__ */ new Map();
|
|
1567
|
+
#files = /* @__PURE__ */ new Map();
|
|
1568
|
+
#diagnostics = /* @__PURE__ */ new Map();
|
|
1569
|
+
#overlay = new Overlay();
|
|
1570
|
+
#recycled;
|
|
1571
|
+
#closing;
|
|
1572
|
+
#progress = 0;
|
|
1573
|
+
#destroyed = false;
|
|
1574
|
+
/**
|
|
1575
|
+
* Starts warming the target workspace's TypeScript service.
|
|
1576
|
+
*
|
|
1577
|
+
* @param workspace - The target workspace root. Default: the current working directory
|
|
1578
|
+
*/
|
|
1579
|
+
constructor(workspace = process.cwd()) {
|
|
1580
|
+
this.#workspace = workspace;
|
|
1581
|
+
const typescript = loadWorkspaceModule(this.#workspace, "typescript");
|
|
1582
|
+
this.#typescript = this.#warm(typescript);
|
|
1583
|
+
this.#typescript.catch(() => {});
|
|
1584
|
+
}
|
|
1585
|
+
get stage() {
|
|
1586
|
+
return "type";
|
|
1587
|
+
}
|
|
1588
|
+
get progress() {
|
|
1589
|
+
return this.#progress;
|
|
1590
|
+
}
|
|
1591
|
+
/**
|
|
1592
|
+
* Inspects one case, against a caller-named project where the caller names one.
|
|
1593
|
+
*
|
|
1594
|
+
* @remarks
|
|
1595
|
+
* The `project` parameter is this stage's own, not the stage contract's: the lint and runtime
|
|
1596
|
+
* stages read no project, so `StageInterface` declares one parameter and every caller that
|
|
1597
|
+
* needs this one holds a `TypeStage`.
|
|
1598
|
+
*
|
|
1599
|
+
* @param subject - The candidate drafts and test to inspect
|
|
1600
|
+
* @param project - The workspace-relative TypeScript project the candidate drafts are checked
|
|
1601
|
+
* against. Default: the scoped project each candidate path infers
|
|
1602
|
+
* @returns One outcome for this stage
|
|
1603
|
+
* @throws When the resident compiler cannot start or the stage has already been destroyed
|
|
1604
|
+
*/
|
|
1605
|
+
async inspect(subject, project) {
|
|
1606
|
+
if (this.#destroyed) throw (0, _src_core.createDestroyedError)("type stage");
|
|
1607
|
+
const started = performance.now();
|
|
1608
|
+
const typescript = await this.#typescript;
|
|
1609
|
+
if (this.#destroyed) throw (0, _src_core.createDestroyedError)("type stage");
|
|
1610
|
+
const selections = subject.files.map((draft) => ({
|
|
1611
|
+
draft,
|
|
1612
|
+
path: resolveWorkspaceFile(this.#workspace, draft.path)
|
|
1613
|
+
})).map((candidate) => ({
|
|
1614
|
+
draft: candidate.draft,
|
|
1615
|
+
project: project ?? inferTypeProject(relativeWorkspaceFile(this.#workspace, candidate.path))
|
|
1616
|
+
}));
|
|
1617
|
+
this.#configure(this.#service(typescript, "tsconfig.json"), "tsconfig.json");
|
|
1618
|
+
for (const selection of selections) this.#configure(this.#service(typescript, selection.project), selection.project);
|
|
1619
|
+
const overlay = new Overlay();
|
|
1620
|
+
this.#overlay = overlay;
|
|
1621
|
+
try {
|
|
1622
|
+
this.#record(subject.test, overlay);
|
|
1623
|
+
for (const draft of subject.files) this.#record(draft, overlay);
|
|
1624
|
+
this.#progress += 1;
|
|
1625
|
+
const issues = [];
|
|
1626
|
+
const projects = /* @__PURE__ */ new Set();
|
|
1627
|
+
const root = this.#service(typescript, "tsconfig.json");
|
|
1628
|
+
issues.push(...this.#issues(typescript, root, subject.test, "tsconfig.json", false, true));
|
|
1629
|
+
projects.add("tsconfig.json");
|
|
1630
|
+
await this.#unblock();
|
|
1631
|
+
for (const selection of selections) {
|
|
1632
|
+
const draft = selection.draft;
|
|
1633
|
+
const selected = selection.project;
|
|
1634
|
+
const service = this.#service(typescript, selected);
|
|
1635
|
+
issues.push(...this.#issues(typescript, service, draft, selected, project !== void 0, !projects.has(selected)));
|
|
1636
|
+
projects.add(selected);
|
|
1637
|
+
await this.#unblock();
|
|
1638
|
+
}
|
|
1639
|
+
return {
|
|
1640
|
+
stage: this.stage,
|
|
1641
|
+
elapsed: Math.round(performance.now() - started),
|
|
1642
|
+
issues
|
|
1643
|
+
};
|
|
1644
|
+
} finally {
|
|
1645
|
+
overlay.clear();
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
/**
|
|
1649
|
+
* Resolves one project to the path and digest this stage applies for it.
|
|
1650
|
+
*
|
|
1651
|
+
* @remarks
|
|
1652
|
+
* Reads the parse this stage itself applies, filling its cache when the project is not already
|
|
1653
|
+
* resident, so the reported digest is the configuration the inspection is judged under rather
|
|
1654
|
+
* than a second parse a caller ran. The returned record is a value copy, so a later eviction
|
|
1655
|
+
* does not move it.
|
|
1656
|
+
*
|
|
1657
|
+
* @param project - The workspace-relative TypeScript project to resolve
|
|
1658
|
+
* @returns The resolved workspace-relative path and the digest of its compiler options
|
|
1659
|
+
* @throws When the project escapes the workspace, cannot be parsed, or the stage has already
|
|
1660
|
+
* been destroyed
|
|
1661
|
+
*/
|
|
1662
|
+
async resolve(project) {
|
|
1663
|
+
if (this.#destroyed) throw (0, _src_core.createDestroyedError)("type stage");
|
|
1664
|
+
const typescript = await this.#typescript;
|
|
1665
|
+
if (this.#destroyed) throw (0, _src_core.createDestroyedError)("type stage");
|
|
1666
|
+
const resolved = resolveWorkspaceFile(this.#workspace, project);
|
|
1667
|
+
this.#service(typescript, project);
|
|
1668
|
+
return {
|
|
1669
|
+
path: relativeWorkspaceFile(this.#workspace, resolved),
|
|
1670
|
+
digest: computeDigest(this.#workspace, this.#options.get(resolved) ?? {})
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
destroy() {
|
|
1674
|
+
if (this.#closing !== void 0) return this.#closing;
|
|
1675
|
+
this.#destroyed = true;
|
|
1676
|
+
this.#closing = this.#destroy();
|
|
1677
|
+
return this.#closing;
|
|
1678
|
+
}
|
|
1679
|
+
async #destroy() {
|
|
1680
|
+
await this.#typescript.catch(() => void 0);
|
|
1681
|
+
for (const service of this.#services.values()) service.dispose();
|
|
1682
|
+
this.#services.clear();
|
|
1683
|
+
this.#resident.clear();
|
|
1684
|
+
this.#recycled = void 0;
|
|
1685
|
+
this.#options.clear();
|
|
1686
|
+
this.#files.clear();
|
|
1687
|
+
this.#diagnostics.clear();
|
|
1688
|
+
}
|
|
1689
|
+
async #warm(typescript) {
|
|
1690
|
+
for (const project of this.#projects()) {
|
|
1691
|
+
this.#resident.add(resolveWorkspaceFile(this.#workspace, project));
|
|
1692
|
+
this.#service(typescript, project);
|
|
1693
|
+
}
|
|
1694
|
+
return typescript;
|
|
1695
|
+
}
|
|
1696
|
+
#projects() {
|
|
1697
|
+
const projects = ["tsconfig.json"];
|
|
1698
|
+
for (const axis of ["src", "app"]) {
|
|
1699
|
+
const directory = resolveWorkspaceFile(this.#workspace, `configs/${axis}`);
|
|
1700
|
+
let names;
|
|
1701
|
+
try {
|
|
1702
|
+
names = (0, node_fs.readdirSync)(directory);
|
|
1703
|
+
} catch {
|
|
1704
|
+
continue;
|
|
1705
|
+
}
|
|
1706
|
+
for (const name of names.filter((entry) => /^tsconfig\.[^.]+\.json$/.test(entry)).sort()) projects.push(`configs/${axis}/${name}`);
|
|
1707
|
+
}
|
|
1708
|
+
return projects;
|
|
1709
|
+
}
|
|
1710
|
+
async #unblock() {
|
|
1711
|
+
await (0, node_timers_promises.setImmediate)();
|
|
1712
|
+
if (this.#destroyed) throw (0, _src_core.createDestroyedError)("type stage");
|
|
1713
|
+
}
|
|
1714
|
+
#record(draft, overlay) {
|
|
1715
|
+
overlay.set(resolveWorkspaceFile(this.#workspace, draft.path), draft.text);
|
|
1716
|
+
}
|
|
1717
|
+
#service(typescript, project) {
|
|
1718
|
+
const path = resolveWorkspaceFile(this.#workspace, project);
|
|
1719
|
+
const existing = this.#services.get(path);
|
|
1720
|
+
if (existing !== void 0) return existing;
|
|
1721
|
+
const config = typescript.readConfigFile(path, typescript.sys.readFile);
|
|
1722
|
+
if (config.error !== void 0) throw new _src_core.ProbeError(typescript.flattenDiagnosticMessageText(config.error.messageText, "\n"), {
|
|
1723
|
+
origin: "workspace",
|
|
1724
|
+
code: "malformed",
|
|
1725
|
+
context: {
|
|
1726
|
+
stage: this.stage,
|
|
1727
|
+
project
|
|
1728
|
+
}
|
|
1729
|
+
});
|
|
1730
|
+
const parsed = typescript.parseJsonConfigFileContent(config.config, typescript.sys, (0, node_path.dirname)(path), void 0, path);
|
|
1731
|
+
if (parsed.errors.length > 0) throw new _src_core.ProbeError(typescript.flattenDiagnosticMessageText(parsed.errors[0]?.messageText ?? "", "\n"), {
|
|
1732
|
+
origin: "workspace",
|
|
1733
|
+
code: "malformed",
|
|
1734
|
+
context: {
|
|
1735
|
+
stage: this.stage,
|
|
1736
|
+
project
|
|
1737
|
+
}
|
|
1738
|
+
});
|
|
1739
|
+
this.#options.set(path, parsed.options);
|
|
1740
|
+
this.#files.set(path, parsed.fileNames);
|
|
1741
|
+
const service = typescript.createLanguageService({
|
|
1742
|
+
getCompilationSettings: () => this.#options.get(path) ?? {},
|
|
1743
|
+
getScriptFileNames: () => [...this.#files.get(path) ?? [], ...this.#overlay.paths],
|
|
1744
|
+
getScriptVersion: (file) => this.#version(file),
|
|
1745
|
+
getScriptSnapshot: (file) => this.#snapshot(typescript, file),
|
|
1746
|
+
getCurrentDirectory: () => this.#workspace,
|
|
1747
|
+
getDefaultLibFileName: (options) => typescript.getDefaultLibFilePath(options),
|
|
1748
|
+
fileExists: (file) => this.#overlay.text(file) !== void 0 || typescript.sys.fileExists(file),
|
|
1749
|
+
readFile: (file) => this.#overlay.text(file) ?? typescript.sys.readFile(file),
|
|
1750
|
+
readDirectory: typescript.sys.readDirectory,
|
|
1751
|
+
directoryExists: (directory) => typescript.sys.directoryExists(directory) || this.#overlay.covers(directory),
|
|
1752
|
+
getDirectories: typescript.sys.getDirectories,
|
|
1753
|
+
useCaseSensitiveFileNames: () => typescript.sys.useCaseSensitiveFileNames,
|
|
1754
|
+
getNewLine: () => typescript.sys.newLine
|
|
1755
|
+
});
|
|
1756
|
+
this.#services.set(path, service);
|
|
1757
|
+
if (!this.#resident.has(path)) this.#recycle(path);
|
|
1758
|
+
return service;
|
|
1759
|
+
}
|
|
1760
|
+
#configure(service, project) {
|
|
1761
|
+
const path = resolveWorkspaceFile(this.#workspace, project);
|
|
1762
|
+
if (this.#diagnostics.has(path)) return;
|
|
1763
|
+
this.#diagnostics.set(path, service.getCompilerOptionsDiagnostics());
|
|
1764
|
+
}
|
|
1765
|
+
#recycle(path) {
|
|
1766
|
+
const previous = this.#recycled;
|
|
1767
|
+
this.#recycled = path;
|
|
1768
|
+
if (previous === void 0 || previous === path) return;
|
|
1769
|
+
this.#services.get(previous)?.dispose();
|
|
1770
|
+
this.#services.delete(previous);
|
|
1771
|
+
this.#options.delete(previous);
|
|
1772
|
+
this.#files.delete(previous);
|
|
1773
|
+
this.#diagnostics.delete(previous);
|
|
1774
|
+
}
|
|
1775
|
+
#version(file) {
|
|
1776
|
+
if (this.#overlay.text(file) !== void 0) return `virtual:${this.#overlay.revision}`;
|
|
1777
|
+
try {
|
|
1778
|
+
return `disk:${(0, node_fs.statSync)(file).mtimeMs}`;
|
|
1779
|
+
} catch {
|
|
1780
|
+
return "missing";
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
#snapshot(typescript, file) {
|
|
1784
|
+
const text = this.#overlay.text(file) ?? typescript.sys.readFile(file);
|
|
1785
|
+
return text === void 0 ? void 0 : typescript.ScriptSnapshot.fromString(text);
|
|
1786
|
+
}
|
|
1787
|
+
#issues(typescript, service, draft, project, selected, configure) {
|
|
1788
|
+
const path = resolveWorkspaceFile(this.#workspace, draft.path);
|
|
1789
|
+
const configuration = resolveWorkspaceFile(this.#workspace, project);
|
|
1790
|
+
return [
|
|
1791
|
+
...configure ? this.#diagnostics.get(configuration) ?? [] : [],
|
|
1792
|
+
...service.getSyntacticDiagnostics(path),
|
|
1793
|
+
...service.getSemanticDiagnostics(path)
|
|
1794
|
+
].map((diagnostic) => this.#issue(typescript, diagnostic, project, selected));
|
|
1795
|
+
}
|
|
1796
|
+
#issue(typescript, diagnostic, project, selected) {
|
|
1797
|
+
const message = typescript.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
|
|
1798
|
+
if (diagnostic.file === void 0) {
|
|
1799
|
+
if (selected) throw new _src_core.ProbeError(message, {
|
|
1800
|
+
origin: "claimant",
|
|
1801
|
+
code: "refused",
|
|
1802
|
+
context: {
|
|
1803
|
+
stage: this.stage,
|
|
1804
|
+
project
|
|
1805
|
+
}
|
|
1806
|
+
});
|
|
1807
|
+
return {
|
|
1808
|
+
origin: "instrument",
|
|
1809
|
+
path: project,
|
|
1810
|
+
message
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
const path = relativeWorkspaceFile(this.#workspace, diagnostic.file.fileName);
|
|
1814
|
+
if (diagnostic.start === void 0) return {
|
|
1815
|
+
origin: "claimant",
|
|
1816
|
+
path,
|
|
1817
|
+
message
|
|
1818
|
+
};
|
|
1819
|
+
return {
|
|
1820
|
+
origin: "claimant",
|
|
1821
|
+
path,
|
|
1822
|
+
message,
|
|
1823
|
+
line: diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start).line + 1
|
|
1824
|
+
};
|
|
1825
|
+
}
|
|
1826
|
+
};
|
|
1827
|
+
//#endregion
|
|
1828
|
+
//#region src/server/Probe.ts
|
|
1829
|
+
/**
|
|
1830
|
+
* Answers claims through resident TypeScript, Oxlint, and Vitest stages.
|
|
1831
|
+
*
|
|
1832
|
+
* @remarks
|
|
1833
|
+
* Construction resolves the target workspace's toolchain and begins warming every stage. The boot
|
|
1834
|
+
* controls mutate imported dependencies and refuse service unless the type and runtime stages
|
|
1835
|
+
* report their respective changes. One queue per stage admits inspections in arrival order, one at
|
|
1836
|
+
* a time, so a stage never serves two claims at once and the deadline covers active work rather
|
|
1837
|
+
* than queue wait. Each active stage inspection has a coordinator-owned deadline. An expiry at any
|
|
1838
|
+
* stage abandons that stage and replaces it before the next queued inspection begins, so one slow
|
|
1839
|
+
* claim costs that claim rather than the process. A failed boot is replaced the same way: the next
|
|
1840
|
+
* claim runs the controls again rather than inheriting a refusal.
|
|
1841
|
+
*
|
|
1842
|
+
* @example
|
|
1843
|
+
* ```ts
|
|
1844
|
+
* const probe = new Probe({ workspace: '/srv/checkout' })
|
|
1845
|
+
* const verdict = await probe.prove(claim)
|
|
1846
|
+
* await probe.destroy()
|
|
1847
|
+
* ```
|
|
1848
|
+
*/
|
|
1849
|
+
var Probe = class {
|
|
1850
|
+
#workspace;
|
|
1851
|
+
#deadline;
|
|
1852
|
+
#emitter;
|
|
1853
|
+
#toolchain;
|
|
1854
|
+
#type;
|
|
1855
|
+
#lint;
|
|
1856
|
+
#runtime;
|
|
1857
|
+
#typeQueue;
|
|
1858
|
+
#lintQueue;
|
|
1859
|
+
#runtimeQueue;
|
|
1860
|
+
#arming;
|
|
1861
|
+
#closing;
|
|
1862
|
+
#destroyed = false;
|
|
1863
|
+
/**
|
|
1864
|
+
* Resolves the target toolchain and starts warming the resident stages.
|
|
1865
|
+
*
|
|
1866
|
+
* @param options - Workspace, deadline, and initial observation hooks
|
|
1867
|
+
*/
|
|
1868
|
+
constructor(options) {
|
|
1869
|
+
this.#workspace = options?.workspace ?? process.cwd();
|
|
1870
|
+
this.#deadline = (0, _orkestrel_timeout.createTimeout)({ ms: options?.deadline ?? 3e4 }).ms;
|
|
1871
|
+
this.#emitter = new _orkestrel_emitter.Emitter({
|
|
1872
|
+
...options?.on === void 0 ? {} : { on: options.on },
|
|
1873
|
+
...options?.error === void 0 ? {} : { error: options.error }
|
|
1874
|
+
});
|
|
1875
|
+
this.#toolchain = Object.freeze({
|
|
1876
|
+
typescript: this.#version("typescript"),
|
|
1877
|
+
oxlint: this.#version("oxlint"),
|
|
1878
|
+
vitest: this.#version("vitest")
|
|
1879
|
+
});
|
|
1880
|
+
this.#type = new TypeStage(this.#workspace);
|
|
1881
|
+
this.#lint = new LintStage(this.#workspace);
|
|
1882
|
+
this.#runtime = new RuntimeStage(this.#workspace);
|
|
1883
|
+
this.#typeQueue = (0, _orkestrel_queue.createQueue)({
|
|
1884
|
+
concurrency: 1,
|
|
1885
|
+
retries: 0,
|
|
1886
|
+
handler: (inspection) => this.#inspectStage(this.#type, this.#type.progress, this.#type.inspect(inspection.subject, inspection.claim.project), inspection.claim)
|
|
1887
|
+
});
|
|
1888
|
+
this.#lintQueue = (0, _orkestrel_queue.createQueue)({
|
|
1889
|
+
concurrency: 1,
|
|
1890
|
+
retries: 0,
|
|
1891
|
+
handler: (inspection) => this.#inspectStage(this.#lint, this.#lint.progress, this.#lint.inspect(inspection.subject), inspection.claim)
|
|
1892
|
+
});
|
|
1893
|
+
this.#runtimeQueue = (0, _orkestrel_queue.createQueue)({
|
|
1894
|
+
concurrency: 1,
|
|
1895
|
+
retries: 0,
|
|
1896
|
+
handler: (inspection) => this.#inspectStage(this.#runtime, this.#runtime.progress, this.#runtime.inspect(inspection.subject), inspection.claim)
|
|
1897
|
+
});
|
|
1898
|
+
this.#arming = this.#arm();
|
|
1899
|
+
this.#arming.catch(() => {});
|
|
1900
|
+
}
|
|
1901
|
+
get emitter() {
|
|
1902
|
+
return this.#emitter;
|
|
1903
|
+
}
|
|
1904
|
+
get toolchain() {
|
|
1905
|
+
return this.#toolchain;
|
|
1906
|
+
}
|
|
1907
|
+
async prove(claim) {
|
|
1908
|
+
try {
|
|
1909
|
+
this.#support();
|
|
1910
|
+
await this.#ready();
|
|
1911
|
+
if (this.#destroyed) throw (0, _src_core.createDestroyedError)("probe");
|
|
1912
|
+
const started = performance.now();
|
|
1913
|
+
const id = (0, node_crypto.randomUUID)();
|
|
1914
|
+
const project = await this.#type.resolve(claim.project);
|
|
1915
|
+
const digest = computeDigest(this.#workspace, {
|
|
1916
|
+
case: claim.case,
|
|
1917
|
+
control: claim.control
|
|
1918
|
+
});
|
|
1919
|
+
const subject = Object.freeze(await this.#inspect(claim.case, claim));
|
|
1920
|
+
const control = Object.freeze(await this.#inspect(claim.control, claim));
|
|
1921
|
+
const basis = {
|
|
1922
|
+
id,
|
|
1923
|
+
digest,
|
|
1924
|
+
toolchain: this.#toolchain,
|
|
1925
|
+
project,
|
|
1926
|
+
reason: claim.control.reason,
|
|
1927
|
+
case: subject,
|
|
1928
|
+
control,
|
|
1929
|
+
elapsed: Math.round(performance.now() - started)
|
|
1930
|
+
};
|
|
1931
|
+
const receipt = (0, _src_core.computeReceipt)(basis, claim.control.stage);
|
|
1932
|
+
const verdict = receipt === void 0 ? basis : {
|
|
1933
|
+
...basis,
|
|
1934
|
+
receipt
|
|
1935
|
+
};
|
|
1936
|
+
this.#emitter.emit("prove", verdict);
|
|
1937
|
+
return verdict;
|
|
1938
|
+
} catch (error) {
|
|
1939
|
+
this.#emitter.emit("error", error);
|
|
1940
|
+
throw error;
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
destroy() {
|
|
1944
|
+
if (this.#closing !== void 0) return this.#closing;
|
|
1945
|
+
this.#destroyed = true;
|
|
1946
|
+
this.#closing = this.#destroy();
|
|
1947
|
+
return this.#closing;
|
|
1948
|
+
}
|
|
1949
|
+
async #ready() {
|
|
1950
|
+
const attempt = this.#arming;
|
|
1951
|
+
try {
|
|
1952
|
+
await attempt;
|
|
1953
|
+
return;
|
|
1954
|
+
} catch (error) {
|
|
1955
|
+
if (this.#destroyed) throw error;
|
|
1956
|
+
}
|
|
1957
|
+
if (this.#arming === attempt) {
|
|
1958
|
+
this.#arming = this.#arm();
|
|
1959
|
+
this.#arming.catch(() => {});
|
|
1960
|
+
}
|
|
1961
|
+
await this.#arming;
|
|
1962
|
+
}
|
|
1963
|
+
async #arm() {
|
|
1964
|
+
const created = this.#workbench();
|
|
1965
|
+
try {
|
|
1966
|
+
await this.#boot(created);
|
|
1967
|
+
} catch (error) {
|
|
1968
|
+
throw new _src_core.ProbeError(`The probe could not arm: ${describeUnknown(error)}`, {
|
|
1969
|
+
origin: "instrument",
|
|
1970
|
+
code: "malformed",
|
|
1971
|
+
cause: error
|
|
1972
|
+
});
|
|
1973
|
+
}
|
|
1974
|
+
this.#emitter.emit("arm", this.#toolchain);
|
|
1975
|
+
}
|
|
1976
|
+
#workbench() {
|
|
1977
|
+
const path = "tmp/probe";
|
|
1978
|
+
const directory = resolveWorkspaceFile(this.#workspace, path);
|
|
1979
|
+
const created = !(0, node_fs.existsSync)(directory);
|
|
1980
|
+
try {
|
|
1981
|
+
(0, node_fs.mkdirSync)(resolveWorkspaceFile(this.#workspace, path, true), { recursive: true });
|
|
1982
|
+
} catch (error) {
|
|
1983
|
+
const code = error instanceof _src_core.ProbeError && error.origin === "workspace" ? error.code : "malformed";
|
|
1984
|
+
throw new _src_core.ProbeError(`The probe could not create the boot workbench (${describeUnknown(error)})`, {
|
|
1985
|
+
origin: "workspace",
|
|
1986
|
+
code,
|
|
1987
|
+
context: { path },
|
|
1988
|
+
cause: error
|
|
1989
|
+
});
|
|
1990
|
+
}
|
|
1991
|
+
return created;
|
|
1992
|
+
}
|
|
1993
|
+
async #boot(created) {
|
|
1994
|
+
const revision = `${process.pid}-${(0, node_crypto.randomUUID)()}`;
|
|
1995
|
+
const typeDependency = createRevisionFile(this.#workspace, "tmp/probe/arm-type.ts", revision);
|
|
1996
|
+
const runtimeDependency = createRevisionFile(this.#workspace, "tmp/probe/arm-runtime.ts", revision);
|
|
1997
|
+
const typeModule = (0, node_path.basename)(typeDependency, ".ts");
|
|
1998
|
+
const runtimeModule = (0, node_path.basename)(runtimeDependency, ".ts");
|
|
1999
|
+
const typeTest = {
|
|
2000
|
+
path: `tmp/probe/${typeModule}.test.ts`,
|
|
2001
|
+
text: `import type { Signal } from './${typeModule}.js'\nimport { expect, test } from 'vitest'\nconst SIGNAL: Signal = 'before'\ntest('revalidates a mutated type', () => {\n\texpect(SIGNAL).toBe('before')\n})\n`
|
|
2002
|
+
};
|
|
2003
|
+
const runtimeTest = {
|
|
2004
|
+
path: `tmp/probe/${runtimeModule}.test.ts`,
|
|
2005
|
+
text: `import { SIGNAL } from './${runtimeModule}.js'\nimport { expect, test } from 'vitest'\ntest('revalidates a mutated value', () => {\n\texpect(SIGNAL).toBe('before')\n})\n`
|
|
2006
|
+
};
|
|
2007
|
+
const typeClaim = {
|
|
2008
|
+
project: "tsconfig.json",
|
|
2009
|
+
case: {
|
|
2010
|
+
files: [],
|
|
2011
|
+
test: typeTest
|
|
2012
|
+
},
|
|
2013
|
+
control: {
|
|
2014
|
+
files: [],
|
|
2015
|
+
test: typeTest,
|
|
2016
|
+
stage: "type",
|
|
2017
|
+
reason: "the imported type changed after the resident type host cached it"
|
|
2018
|
+
}
|
|
2019
|
+
};
|
|
2020
|
+
const runtimeClaim = {
|
|
2021
|
+
project: "tsconfig.json",
|
|
2022
|
+
case: {
|
|
2023
|
+
files: [],
|
|
2024
|
+
test: runtimeTest
|
|
2025
|
+
},
|
|
2026
|
+
control: {
|
|
2027
|
+
files: [],
|
|
2028
|
+
test: runtimeTest,
|
|
2029
|
+
stage: "runtime",
|
|
2030
|
+
reason: "the imported dependency changed after the resident runtime cached it"
|
|
2031
|
+
}
|
|
2032
|
+
};
|
|
2033
|
+
try {
|
|
2034
|
+
resolveWorkspaceFile(this.#workspace, (0, node_path.relative)(this.#workspace, typeDependency), true);
|
|
2035
|
+
resolveWorkspaceFile(this.#workspace, (0, node_path.relative)(this.#workspace, runtimeDependency), true);
|
|
2036
|
+
(0, node_fs.writeFileSync)(typeDependency, (0, _src_core.formatSpecification)("export type Signal = string\n", revision), {
|
|
2037
|
+
encoding: "utf8",
|
|
2038
|
+
flag: "wx"
|
|
2039
|
+
});
|
|
2040
|
+
(0, node_fs.writeFileSync)(runtimeDependency, (0, _src_core.formatSpecification)("export const SIGNAL = 'before'\n", revision), {
|
|
2041
|
+
encoding: "utf8",
|
|
2042
|
+
flag: "wx"
|
|
2043
|
+
});
|
|
2044
|
+
const beforeType = await this.#inspect(typeClaim.case, typeClaim);
|
|
2045
|
+
const beforeRuntime = await this.#inspect(runtimeClaim.case, runtimeClaim);
|
|
2046
|
+
const before = [...beforeType, ...beforeRuntime];
|
|
2047
|
+
if (before.some((check) => check.issues.length > 0)) throw new _src_core.ProbeError(`The probe boot control did not begin clean\n${before.map(_src_core.formatCheck).join("\n")}`, {
|
|
2048
|
+
origin: "instrument",
|
|
2049
|
+
code: "malformed"
|
|
2050
|
+
});
|
|
2051
|
+
resolveWorkspaceFile(this.#workspace, (0, node_path.relative)(this.#workspace, typeDependency), true);
|
|
2052
|
+
(0, node_fs.writeFileSync)(typeDependency, (0, _src_core.formatSpecification)("export type Signal = number\n", revision), "utf8");
|
|
2053
|
+
const afterType = await this.#inspect(typeClaim.control, typeClaim);
|
|
2054
|
+
const type = afterType.find((check) => check.stage === typeClaim.control.stage);
|
|
2055
|
+
const tolerant = afterType.find((check) => check.stage === "runtime");
|
|
2056
|
+
if (type === void 0 || type.issues.length === 0) throw new _src_core.ProbeError(`The probe boot type control did not detect a mutated dependency\n${afterType.map(_src_core.formatCheck).join("\n")}`, {
|
|
2057
|
+
origin: "instrument",
|
|
2058
|
+
code: "malformed",
|
|
2059
|
+
context: { stage: typeClaim.control.stage }
|
|
2060
|
+
});
|
|
2061
|
+
if (tolerant === void 0 || tolerant.issues.length > 0) throw new _src_core.ProbeError(`The probe boot type control did not remain runtime-clean\n${afterType.map(_src_core.formatCheck).join("\n")}`, {
|
|
2062
|
+
origin: "instrument",
|
|
2063
|
+
code: "malformed",
|
|
2064
|
+
context: { stage: "runtime" }
|
|
2065
|
+
});
|
|
2066
|
+
resolveWorkspaceFile(this.#workspace, (0, node_path.relative)(this.#workspace, runtimeDependency), true);
|
|
2067
|
+
(0, node_fs.writeFileSync)(runtimeDependency, (0, _src_core.formatSpecification)("export const SIGNAL = 'after'\n", revision), "utf8");
|
|
2068
|
+
const afterRuntime = await this.#inspect(runtimeClaim.control, runtimeClaim);
|
|
2069
|
+
const runtime = afterRuntime.find((check) => check.stage === runtimeClaim.control.stage);
|
|
2070
|
+
if (runtime === void 0 || runtime.issues.length === 0) throw new _src_core.ProbeError(`The probe boot runtime control did not detect a mutated dependency\n${afterRuntime.map(_src_core.formatCheck).join("\n")}`, {
|
|
2071
|
+
origin: "instrument",
|
|
2072
|
+
code: "malformed",
|
|
2073
|
+
context: { stage: runtimeClaim.control.stage }
|
|
2074
|
+
});
|
|
2075
|
+
} finally {
|
|
2076
|
+
(0, node_fs.rmSync)(resolveWorkspaceFile(this.#workspace, (0, node_path.relative)(this.#workspace, typeDependency), true), { force: true });
|
|
2077
|
+
(0, node_fs.rmSync)(resolveWorkspaceFile(this.#workspace, (0, node_path.relative)(this.#workspace, runtimeDependency), true), { force: true });
|
|
2078
|
+
if (created) try {
|
|
2079
|
+
(0, node_fs.rmdirSync)(resolveWorkspaceFile(this.#workspace, "tmp/probe", true));
|
|
2080
|
+
} catch {}
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
#inspect(subject, claim) {
|
|
2084
|
+
const inspection = {
|
|
2085
|
+
subject,
|
|
2086
|
+
claim
|
|
2087
|
+
};
|
|
2088
|
+
const admitted = [
|
|
2089
|
+
this.#typeQueue.enqueue(inspection),
|
|
2090
|
+
this.#lintQueue.enqueue(inspection),
|
|
2091
|
+
this.#runtimeQueue.enqueue(inspection)
|
|
2092
|
+
];
|
|
2093
|
+
for (const pending of admitted) pending.catch(() => {});
|
|
2094
|
+
return Promise.all(admitted);
|
|
2095
|
+
}
|
|
2096
|
+
async #inspectStage(stage, progress, operation, claim) {
|
|
2097
|
+
const timeout = (0, _orkestrel_timeout.createTimeout)({ ms: this.#deadline });
|
|
2098
|
+
timeout.start();
|
|
2099
|
+
try {
|
|
2100
|
+
return await Promise.race([operation, this.#expiry(timeout, `The ${stage.stage} stage exceeded ${this.#deadline} ms`, stage, progress)]);
|
|
2101
|
+
} catch (error) {
|
|
2102
|
+
if (!timeout.expired) throw error;
|
|
2103
|
+
if (await this.#recycle(stage)) this.#emitter.emit("expire", claim);
|
|
2104
|
+
throw error;
|
|
2105
|
+
} finally {
|
|
2106
|
+
timeout.clear();
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
async #recycle(stage) {
|
|
2110
|
+
const timeout = (0, _orkestrel_timeout.createTimeout)({ ms: this.#deadline });
|
|
2111
|
+
timeout.start();
|
|
2112
|
+
try {
|
|
2113
|
+
await Promise.race([stage.destroy(), this.#expiry(timeout, `The ${stage.stage} stage recovery exceeded ${this.#deadline} ms`, stage, stage.progress)]);
|
|
2114
|
+
} catch {} finally {
|
|
2115
|
+
timeout.clear();
|
|
2116
|
+
}
|
|
2117
|
+
if (this.#destroyed) return false;
|
|
2118
|
+
if (stage === this.#type) {
|
|
2119
|
+
this.#type = new TypeStage(this.#workspace);
|
|
2120
|
+
return true;
|
|
2121
|
+
}
|
|
2122
|
+
if (stage === this.#lint) {
|
|
2123
|
+
this.#lint = new LintStage(this.#workspace);
|
|
2124
|
+
return true;
|
|
2125
|
+
}
|
|
2126
|
+
if (stage === this.#runtime) {
|
|
2127
|
+
this.#runtime = new RuntimeStage(this.#workspace);
|
|
2128
|
+
return true;
|
|
2129
|
+
}
|
|
2130
|
+
return false;
|
|
2131
|
+
}
|
|
2132
|
+
#expiry(timeout, message, stage, progress) {
|
|
2133
|
+
return new Promise((_resolve, reject) => {
|
|
2134
|
+
timeout.signal.addEventListener("abort", () => reject(new _src_core.ProbeError(message, {
|
|
2135
|
+
origin: stage.progress > progress ? "claimant" : "instrument",
|
|
2136
|
+
code: "deadline",
|
|
2137
|
+
context: {
|
|
2138
|
+
stage: stage.stage,
|
|
2139
|
+
deadline: this.#deadline
|
|
2140
|
+
}
|
|
2141
|
+
})), { once: true });
|
|
2142
|
+
});
|
|
2143
|
+
}
|
|
2144
|
+
async #destroy() {
|
|
2145
|
+
try {
|
|
2146
|
+
await this.#arming;
|
|
2147
|
+
} catch {}
|
|
2148
|
+
await Promise.all([
|
|
2149
|
+
this.#type.destroy(),
|
|
2150
|
+
this.#lint.destroy(),
|
|
2151
|
+
this.#runtime.destroy()
|
|
2152
|
+
]);
|
|
2153
|
+
}
|
|
2154
|
+
#version(name) {
|
|
2155
|
+
const version = readWorkspaceManifest(this.#workspace, name).contents.version;
|
|
2156
|
+
if (typeof version !== "string") throw new _src_core.ProbeError(`${name} publishes no readable version`, {
|
|
2157
|
+
origin: "workspace",
|
|
2158
|
+
code: "malformed",
|
|
2159
|
+
context: { name }
|
|
2160
|
+
});
|
|
2161
|
+
return version;
|
|
2162
|
+
}
|
|
2163
|
+
#support() {
|
|
2164
|
+
const version = this.#toolchain.typescript;
|
|
2165
|
+
const range = peerDependencies.typescript;
|
|
2166
|
+
const supported = /^\^(\d+)\./u.exec(range)?.[1];
|
|
2167
|
+
const found = /^(\d+)\./u.exec(version)?.[1];
|
|
2168
|
+
if (supported === void 0 || found !== supported) throw new _src_core.ProbeError(`The supported TypeScript range is ${range}; found ${version}`, {
|
|
2169
|
+
origin: "workspace",
|
|
2170
|
+
code: "malformed",
|
|
2171
|
+
context: {
|
|
2172
|
+
name: "typescript",
|
|
2173
|
+
value: version
|
|
2174
|
+
}
|
|
2175
|
+
});
|
|
2176
|
+
}
|
|
2177
|
+
};
|
|
2178
|
+
//#endregion
|
|
2179
|
+
//#region src/server/ProbeServer.ts
|
|
2180
|
+
/**
|
|
2181
|
+
* Serves one probe over this process's Model Context Protocol stdio transport.
|
|
2182
|
+
*
|
|
2183
|
+
* @remarks
|
|
2184
|
+
* Construction creates the probe, publishes the `prove` tool, and binds the dual-era dispatcher to
|
|
2185
|
+
* the stdio transport. The probe begins warming there, so a harness that spawns the entry pays
|
|
2186
|
+
* arming while its client is still handshaking.
|
|
2187
|
+
*
|
|
2188
|
+
* `start` seizes standard input and registers the signals a harness ends a child with, and records
|
|
2189
|
+
* whether standard input was already flowing. Every listener it attaches is held as a field, and
|
|
2190
|
+
* `destroy` removes exactly those by reference, so a listener a host registers while this server is
|
|
2191
|
+
* serving is still attached and still fires afterwards. The transport reads a stream this server
|
|
2192
|
+
* owns rather than this process's standard input, which is what makes that possible: the
|
|
2193
|
+
* transport's own listeners land on that stream, and the only listeners this server ever puts on
|
|
2194
|
+
* `process.stdin` are the `data`, `close`, and `error` forwarders into it. `destroy` pauses the
|
|
2195
|
+
* stream only when this server is what set it flowing and nothing else is reading it, so a process
|
|
2196
|
+
* that outlives this server reads its own standard input again and receives its own signals.
|
|
2197
|
+
* Teardown removes the signal handlers first: it takes seconds when a boot is in flight, and a
|
|
2198
|
+
* harness that signals again means to kill rather than to queue.
|
|
2199
|
+
*
|
|
2200
|
+
* @example
|
|
2201
|
+
* ```ts
|
|
2202
|
+
* const server = new ProbeServer({ workspace: process.cwd() })
|
|
2203
|
+
* server.start()
|
|
2204
|
+
* await server.destroy()
|
|
2205
|
+
* ```
|
|
2206
|
+
*/
|
|
2207
|
+
var ProbeServer = class {
|
|
2208
|
+
#probe;
|
|
2209
|
+
#stream;
|
|
2210
|
+
#transport;
|
|
2211
|
+
#data;
|
|
2212
|
+
#close;
|
|
2213
|
+
#error;
|
|
2214
|
+
#signal;
|
|
2215
|
+
#owns;
|
|
2216
|
+
#closing;
|
|
2217
|
+
/**
|
|
2218
|
+
* Creates the probe this server publishes and binds it to this process's stdio transport.
|
|
2219
|
+
*
|
|
2220
|
+
* @param options - Workspace, deadline, and initial observation hooks for the probe it creates
|
|
2221
|
+
*/
|
|
2222
|
+
constructor(options) {
|
|
2223
|
+
this.#probe = new Probe(options);
|
|
2224
|
+
this.#stream = new node_stream.PassThrough();
|
|
2225
|
+
this.#transport = (0, _orkestrel_mcp_server.createStdioServer)((0, _orkestrel_mcp.createMCPLegacy)(this.#publish()), { input: this.#stream });
|
|
2226
|
+
this.#data = this.#receive.bind(this);
|
|
2227
|
+
this.#close = this.#finish.bind(this);
|
|
2228
|
+
this.#error = this.#fail.bind(this);
|
|
2229
|
+
this.#signal = this.#release.bind(this);
|
|
2230
|
+
}
|
|
2231
|
+
start() {
|
|
2232
|
+
if (this.#closing !== void 0) throw (0, _src_core.createDestroyedError)("probe server");
|
|
2233
|
+
if (this.#owns !== void 0) return;
|
|
2234
|
+
this.#owns = process.stdin.readableFlowing !== true;
|
|
2235
|
+
this.#transport.start();
|
|
2236
|
+
process.stdin.on("data", this.#data);
|
|
2237
|
+
process.stdin.on("close", this.#close);
|
|
2238
|
+
process.stdin.on("error", this.#error);
|
|
2239
|
+
process.on("SIGINT", this.#signal);
|
|
2240
|
+
process.on("SIGTERM", this.#signal);
|
|
2241
|
+
}
|
|
2242
|
+
destroy() {
|
|
2243
|
+
if (this.#closing !== void 0) return this.#closing;
|
|
2244
|
+
this.#closing = this.#destroy();
|
|
2245
|
+
return this.#closing;
|
|
2246
|
+
}
|
|
2247
|
+
async #destroy() {
|
|
2248
|
+
process.removeListener("SIGINT", this.#signal);
|
|
2249
|
+
process.removeListener("SIGTERM", this.#signal);
|
|
2250
|
+
this.#transport.stop();
|
|
2251
|
+
process.stdin.removeListener("data", this.#data);
|
|
2252
|
+
process.stdin.removeListener("close", this.#close);
|
|
2253
|
+
process.stdin.removeListener("error", this.#error);
|
|
2254
|
+
if (this.#owns === true && process.stdin.listenerCount("data") === 0) process.stdin.pause();
|
|
2255
|
+
this.#owns = void 0;
|
|
2256
|
+
this.#stream.destroy();
|
|
2257
|
+
await this.#probe.destroy();
|
|
2258
|
+
}
|
|
2259
|
+
#receive(chunk) {
|
|
2260
|
+
this.#stream.write(chunk);
|
|
2261
|
+
}
|
|
2262
|
+
#finish() {
|
|
2263
|
+
this.#stream.end();
|
|
2264
|
+
}
|
|
2265
|
+
#fail(error) {
|
|
2266
|
+
this.#stream.destroy(error);
|
|
2267
|
+
}
|
|
2268
|
+
#release() {
|
|
2269
|
+
this.destroy();
|
|
2270
|
+
}
|
|
2271
|
+
#publish() {
|
|
2272
|
+
const parameters = (0, _orkestrel_contract.schemaToParameters)((0, _orkestrel_contract.compileSchema)(_src_core.CLAIM_SHAPE));
|
|
2273
|
+
if (parameters === void 0) throw new _src_core.ProbeError("The claim schema cannot be advertised as tool parameters", {
|
|
2274
|
+
origin: "instrument",
|
|
2275
|
+
code: "malformed"
|
|
2276
|
+
});
|
|
2277
|
+
const tools = (0, _orkestrel_tool.createToolManager)();
|
|
2278
|
+
tools.add((0, _orkestrel_tool.createTool)({
|
|
2279
|
+
name: "prove",
|
|
2280
|
+
description: "Proves a claim with type, lint, and runtime evidence.",
|
|
2281
|
+
parameters,
|
|
2282
|
+
execute: async (input) => {
|
|
2283
|
+
if (!(0, _src_core.isClaim)(input)) {
|
|
2284
|
+
const refused = findRefusedPaths(input);
|
|
2285
|
+
throw new _src_core.ProbeError(refused.length === 0 ? "The prove tool requires a claim matching the advertised schema" : `The prove tool refuses ${refused.join(", ")}: a draft path must stay inside the workspace, which the advertised schema does not constrain`, {
|
|
2286
|
+
origin: "claimant",
|
|
2287
|
+
code: "refused",
|
|
2288
|
+
context: { value: input }
|
|
2289
|
+
});
|
|
2290
|
+
}
|
|
2291
|
+
return this.#probe.prove(input);
|
|
2292
|
+
}
|
|
2293
|
+
}));
|
|
2294
|
+
return (0, _orkestrel_mcp.createMCPServer)({
|
|
2295
|
+
identity: {
|
|
2296
|
+
name: "probe",
|
|
2297
|
+
version
|
|
2298
|
+
},
|
|
2299
|
+
tools,
|
|
2300
|
+
execution: async ({ call }) => {
|
|
2301
|
+
const result = await tools.execute(call);
|
|
2302
|
+
if (!result.success) return result;
|
|
2303
|
+
if (!(0, _src_core.isVerdict)(result.value)) throw new _src_core.ProbeError("The prove tool returned an invalid verdict", {
|
|
2304
|
+
origin: "instrument",
|
|
2305
|
+
code: "malformed",
|
|
2306
|
+
context: { value: result.value }
|
|
2307
|
+
});
|
|
2308
|
+
return {
|
|
2309
|
+
resultType: "complete",
|
|
2310
|
+
content: [{
|
|
2311
|
+
type: "text",
|
|
2312
|
+
text: (0, _src_core.formatVerdict)(result.value)
|
|
2313
|
+
}]
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2316
|
+
});
|
|
2317
|
+
}
|
|
2318
|
+
};
|
|
2319
|
+
//#endregion
|
|
2320
|
+
exports.LintStage = LintStage;
|
|
2321
|
+
exports.Overlay = Overlay;
|
|
2322
|
+
exports.Probe = Probe;
|
|
2323
|
+
exports.ProbeServer = ProbeServer;
|
|
2324
|
+
exports.RuntimeStage = RuntimeStage;
|
|
2325
|
+
exports.TypeStage = TypeStage;
|
|
2326
|
+
exports.captureListeners = captureListeners;
|
|
2327
|
+
exports.computeDigest = computeDigest;
|
|
2328
|
+
exports.createRevisionFile = createRevisionFile;
|
|
2329
|
+
exports.describeUnknown = describeUnknown;
|
|
2330
|
+
exports.findRefusedPaths = findRefusedPaths;
|
|
2331
|
+
exports.inferDocumentLanguage = inferDocumentLanguage;
|
|
2332
|
+
exports.inferTestProject = inferTestProject;
|
|
2333
|
+
exports.inferTypeProject = inferTypeProject;
|
|
2334
|
+
exports.loadWorkspaceModule = loadWorkspaceModule;
|
|
2335
|
+
exports.matchesWorkspaceModule = matchesWorkspaceModule;
|
|
2336
|
+
exports.normalizePath = normalizePath;
|
|
2337
|
+
exports.normalizeValue = normalizeValue;
|
|
2338
|
+
exports.parseContentLength = parseContentLength;
|
|
2339
|
+
exports.readWorkspaceManifest = readWorkspaceManifest;
|
|
2340
|
+
exports.relativeWorkspaceFile = relativeWorkspaceFile;
|
|
2341
|
+
exports.releaseListeners = releaseListeners;
|
|
2342
|
+
exports.resolveWorkspaceBinary = resolveWorkspaceBinary;
|
|
2343
|
+
exports.resolveWorkspaceFile = resolveWorkspaceFile;
|
|
2344
|
+
exports.resolveWorkspaceModule = resolveWorkspaceModule;
|
|
2345
|
+
|
|
2346
|
+
//# sourceMappingURL=index.cjs.map
|