@orkestrel/probe 0.0.11 → 0.0.13

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.
@@ -12,7 +12,7 @@ let _orkestrel_timeout = require("@orkestrel/timeout");
12
12
  let _orkestrel_lsp = require("@orkestrel/lsp");
13
13
  let _orkestrel_lsp_server = require("@orkestrel/lsp/server");
14
14
  let node_stream = require("node:stream");
15
- let node_timers_promises = require("node:timers/promises");
15
+ let node_child_process = require("node:child_process");
16
16
  let _orkestrel_mcp = require("@orkestrel/mcp");
17
17
  let _orkestrel_mcp_server = require("@orkestrel/mcp/server");
18
18
  let _orkestrel_tool = require("@orkestrel/tool");
@@ -38,6 +38,64 @@ function normalizePath(path) {
38
38
  return path.replaceAll("\\", "/");
39
39
  }
40
40
  /**
41
+ * Reads the condition code a native fault carries.
42
+ *
43
+ * @remarks
44
+ * A caller catches `unknown`, so every reading of a caught value's own property calls into code
45
+ * this package does not own: a proxy whose `has` trap throws and a value whose `code` getter throws
46
+ * both arrive here. The read is guarded, so a fault that refuses inspection reports no code rather
47
+ * than raising a second fault over the first. A `code` the host reports as anything other than a
48
+ * string is no code this package compares against, so it reports none either.
49
+ *
50
+ * One reading serves every site that classifies a native fault, so a host condition is admitted the
51
+ * same way wherever it is read.
52
+ *
53
+ * @param error - The caught value to read
54
+ * @returns The fault's `code` when it carries a string one; `undefined` otherwise
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * readFaultCode(Object.assign(new Error('no such file'), { code: 'ENOENT' })) // 'ENOENT'
59
+ * readFaultCode(new Error('the runtime stage was destroyed')) // undefined
60
+ * readFaultCode('ENOENT') // undefined
61
+ * ```
62
+ */
63
+ function readFaultCode(error) {
64
+ if (typeof error !== "object" || error === null) return void 0;
65
+ const fault = error;
66
+ const reading = (0, _orkestrel_contract.attempt)(() => "code" in fault ? fault.code : void 0);
67
+ if (!reading.success) return void 0;
68
+ return typeof reading.value === "string" ? reading.value : void 0;
69
+ }
70
+ /**
71
+ * Reports whether one path resolves outside the root it is read against.
72
+ *
73
+ * @remarks
74
+ * Containment decides which paths a claim may reach and which absolute paths a digest may rewrite,
75
+ * so one reading serves every site that asks the question. A target resolving to the root itself is
76
+ * contained, and each caller decides on its own what that empty relative path means for it.
77
+ *
78
+ * The test reads the relative path rather than a string prefix of the root, so a sibling directory
79
+ * whose name begins with the root's own text is outside rather than beneath it. An absolute
80
+ * relative path is the answer a host returns for two paths on different volumes, which no root
81
+ * contains.
82
+ *
83
+ * @param root - The root the target is read against
84
+ * @param target - The path to test, absolute or root-relative
85
+ * @returns True if the target resolves outside the root; false otherwise
86
+ *
87
+ * @example
88
+ * ```ts
89
+ * escapesRoot('/srv/checkout', 'src/core/greeting.ts') // false
90
+ * escapesRoot('/srv/checkout', '../secrets.env') // true
91
+ * escapesRoot('/srv/checkout', '/srv/checkout-backup/secrets.env') // true
92
+ * ```
93
+ */
94
+ function escapesRoot(root, target) {
95
+ const path = (0, node_path.relative)((0, node_path.resolve)(root), (0, node_path.resolve)(root, target));
96
+ return path === ".." || path.startsWith(`..${node_path.sep}`) || (0, node_path.isAbsolute)(path);
97
+ }
98
+ /**
41
99
  * Resolves a path inside a target workspace and rejects traversal outside it.
42
100
  *
43
101
  * @remarks
@@ -65,7 +123,7 @@ function resolveWorkspaceFile(workspace, target, mutate = false) {
65
123
  const root = (0, node_path.resolve)(workspace);
66
124
  const file = (0, node_path.resolve)(root, target);
67
125
  const path = (0, node_path.relative)(root, file);
68
- if (path === "" || path === ".." || path.startsWith(`..${node_path.sep}`) || (0, node_path.isAbsolute)(path)) throw new _src_core.ProbeError(`Path escapes the workspace: ${target}`, {
126
+ if (path === "" || escapesRoot(root, file)) throw new _src_core.ProbeError(`Path escapes the workspace: ${target}`, {
69
127
  origin: "claimant",
70
128
  code: "refused",
71
129
  context: { path: target }
@@ -78,18 +136,16 @@ function resolveWorkspaceFile(workspace, target, mutate = false) {
78
136
  descendant = (0, node_path.resolve)(descendant, segment);
79
137
  const outcome = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(descendant));
80
138
  if (!outcome.success) {
81
- const error = outcome.error;
82
- if (typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR")) break;
83
- throw error;
139
+ const code = readFaultCode(outcome.error);
140
+ if (code === "ENOENT" || code === "ENOTDIR") break;
141
+ throw outcome.error;
84
142
  }
85
143
  if (outcome.value.isSymbolicLink()) throw new _src_core.ProbeError(`Path crosses a symbolic link: ${target}`, {
86
144
  origin: "workspace",
87
145
  code: "refused",
88
146
  context: { path: target }
89
147
  });
90
- const resolved = (0, node_fs.realpathSync)(descendant);
91
- const remainder = (0, node_path.relative)(canonical, resolved);
92
- if (remainder === ".." || remainder.startsWith(`..${node_path.sep}`) || (0, node_path.isAbsolute)(remainder)) throw new _src_core.ProbeError(`Path escapes the workspace: ${target}`, {
148
+ if (escapesRoot(canonical, (0, node_fs.realpathSync)(descendant))) throw new _src_core.ProbeError(`Path escapes the workspace: ${target}`, {
93
149
  origin: "claimant",
94
150
  code: "refused",
95
151
  context: { path: target }
@@ -97,7 +153,8 @@ function resolveWorkspaceFile(workspace, target, mutate = false) {
97
153
  }
98
154
  } catch (error) {
99
155
  if (error instanceof _src_core.ProbeError) throw error;
100
- const claimant = typeof error === "object" && error !== null && "code" in error && (error.code === "ENAMETOOLONG" || error.code === "ERR_INVALID_ARG_VALUE");
156
+ const code = readFaultCode(error);
157
+ const claimant = code === "ENAMETOOLONG" || code === "ERR_INVALID_ARG_VALUE";
101
158
  throw new _src_core.ProbeError(`The workspace path cannot be inspected: ${target}`, {
102
159
  origin: claimant ? "claimant" : "workspace",
103
160
  code: claimant ? "refused" : "malformed",
@@ -177,7 +234,7 @@ function overwriteFile(file, text) {
177
234
  *
178
235
  * @param file - The path whose creation failed
179
236
  * @param error - The fault the failed operation raised
180
- * @returns True when the fault means the host refuses this name; false otherwise
237
+ * @returns True if the fault means the host refuses this name; false otherwise
181
238
  *
182
239
  * @example
183
240
  * ```ts
@@ -190,11 +247,7 @@ function overwriteFile(file, text) {
190
247
  * ```
191
248
  */
192
249
  function isRefusedName(file, error) {
193
- if (typeof error !== "object" || error === null) return false;
194
- const fault = error;
195
- const reading = (0, _orkestrel_contract.attempt)(() => "code" in fault ? fault.code : void 0);
196
- if (!reading.success) return false;
197
- const code = reading.value;
250
+ const code = readFaultCode(error);
198
251
  if (code === "ENAMETOOLONG") return true;
199
252
  if (code === "ERR_INVALID_ARG_VALUE") return file.includes("\0");
200
253
  if (code !== "ENOENT") return false;
@@ -272,6 +325,65 @@ function relativeWorkspaceMessage(workspace, message) {
272
325
  return text;
273
326
  }
274
327
  /**
328
+ * Scans the plain-text output of one compiler run into the diagnostics it reported.
329
+ *
330
+ * @remarks
331
+ * Reads the shape `tsc --pretty false` prints: `path(line,column): error TSnnnn: message` for a
332
+ * diagnostic about a file, and `error TSnnnn: message` for one about a project. A line that carries
333
+ * neither prefix and begins with whitespace is an elaboration of the diagnostic above it and joins
334
+ * that message with a newline; any other line is skipped, so a runtime's own failure text yields no
335
+ * diagnostic and a caller reads that as the fault it is. The compiler counts lines and columns from
336
+ * one and columns in UTF-16 code units, so each location is lowered to the zero-based UTF-16 point
337
+ * `Diagnostic.range` fixes and `end` resolves to `start`, which is the extent this output carries.
338
+ * A warning reads as a diagnostic, because the workspace's own gate reports it the same way. The
339
+ * `TS\d+` token that follows `error` or `warning` is what recognizes the line; nothing is captured
340
+ * from it, because `Diagnostic` carries no field for it.
341
+ *
342
+ * @param text - The compiler's output
343
+ * @returns One record per reported diagnostic, in the compiler's own order
344
+ *
345
+ * @example
346
+ * ```ts
347
+ * scanDiagnostics("src/a.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'.")
348
+ * // [{ path: 'src/a.ts', range: { start: { line: 0, character: 13 }, end: { line: 0, character: 13 } }, message: "Type 'string' is not assignable to type 'number'." }]
349
+ * scanDiagnostics('error TS5058: The specified path does not exist.')
350
+ * // [{ message: 'The specified path does not exist.' }]
351
+ * ```
352
+ */
353
+ function scanDiagnostics(text) {
354
+ const diagnostics = [];
355
+ for (const line of text.split(/\r\n|\n/)) {
356
+ const located = /^(.+)\((\d+),(\d+)\): (?:error|warning) TS\d+: (.*)$/u.exec(line);
357
+ if (located !== null) {
358
+ const point = {
359
+ line: Number.parseInt(located[2] ?? "1", 10) - 1,
360
+ character: Number.parseInt(located[3] ?? "1", 10) - 1
361
+ };
362
+ diagnostics.push({
363
+ path: normalizePath(located[1] ?? ""),
364
+ range: {
365
+ start: point,
366
+ end: point
367
+ },
368
+ message: located[4] ?? ""
369
+ });
370
+ continue;
371
+ }
372
+ const bare = /^(?:error|warning) TS\d+: (.*)$/u.exec(line);
373
+ if (bare !== null) {
374
+ diagnostics.push({ message: bare[1] ?? "" });
375
+ continue;
376
+ }
377
+ const previous = diagnostics.at(-1);
378
+ if (previous === void 0 || !/^\s+\S/u.test(line)) continue;
379
+ diagnostics[diagnostics.length - 1] = {
380
+ ...previous,
381
+ message: `${previous.message}\n${line}`
382
+ };
383
+ }
384
+ return diagnostics;
385
+ }
386
+ /**
275
387
  * Resolves one installed module from the target workspace.
276
388
  *
277
389
  * @param workspace - The target workspace root
@@ -289,7 +401,7 @@ function resolveWorkspaceModule(workspace, specifier) {
289
401
  const outcome = (0, _orkestrel_contract.attempt)(() => (0, node_module.createRequire)((0, node_path.resolve)(workspace, "package.json")).resolve(specifier));
290
402
  if (outcome.success) return outcome.value;
291
403
  const error = outcome.error;
292
- const missing = typeof error === "object" && error !== null && "code" in error && error.code === "MODULE_NOT_FOUND";
404
+ const missing = readFaultCode(error) === "MODULE_NOT_FOUND";
293
405
  throw new _src_core.ProbeError(`The workspace cannot resolve ${specifier}`, {
294
406
  origin: "workspace",
295
407
  code: missing ? "missing" : "malformed",
@@ -297,18 +409,35 @@ function resolveWorkspaceModule(workspace, specifier) {
297
409
  cause: error
298
410
  });
299
411
  }
300
- function loadWorkspaceModule(workspace, specifier) {
412
+ /**
413
+ * Loads the installed `vitest/node` module from a target workspace.
414
+ *
415
+ * @remarks
416
+ * The only tool this package drives in process. The compiler is a process this package spawns
417
+ * rather than a module it loads, so `typescript` is resolved through {@link resolveWorkspaceBinary}
418
+ * instead.
419
+ *
420
+ * @param workspace - The target workspace root
421
+ * @returns The installed `vitest/node` module
422
+ * @throws When the workspace cannot load `vitest/node`
423
+ *
424
+ * @example
425
+ * ```ts
426
+ * const vitest = loadWorkspaceVitest(process.cwd())
427
+ * typeof vitest.createVitest === 'function' // true
428
+ * ```
429
+ */
430
+ function loadWorkspaceVitest(workspace) {
301
431
  const outcome = (0, _orkestrel_contract.attempt)(() => {
302
- const require$1 = (0, node_module.createRequire)((0, node_path.resolve)(workspace, "package.json"));
303
- return specifier === "typescript" ? require$1("typescript") : require$1("vitest/node");
432
+ return (0, node_module.createRequire)((0, node_path.resolve)(workspace, "package.json"))("vitest/node");
304
433
  });
305
434
  if (outcome.success) return outcome.value;
306
435
  const error = outcome.error;
307
- const missing = typeof error === "object" && error !== null && "code" in error && error.code === "MODULE_NOT_FOUND";
308
- throw new _src_core.ProbeError(`The workspace cannot load ${specifier}`, {
436
+ const missing = readFaultCode(error) === "MODULE_NOT_FOUND";
437
+ throw new _src_core.ProbeError("The workspace cannot load vitest/node", {
309
438
  origin: "workspace",
310
439
  code: missing ? "missing" : "malformed",
311
- context: { name: specifier },
440
+ context: { name: "vitest/node" },
312
441
  cause: error
313
442
  });
314
443
  }
@@ -342,7 +471,8 @@ function readWorkspaceManifest(workspace, name) {
342
471
  const reading = (0, _orkestrel_contract.attempt)(() => (0, node_fs.readFileSync)(path, "utf8"));
343
472
  if (!reading.success) {
344
473
  const error = reading.error;
345
- const missing = typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
474
+ const code = readFaultCode(error);
475
+ const missing = code === "ENOENT" || code === "ENOTDIR";
346
476
  throw new _src_core.ProbeError(`${name} does not publish a readable manifest`, {
347
477
  origin: "workspace",
348
478
  code: missing ? "missing" : "malformed",
@@ -380,20 +510,26 @@ function readWorkspaceManifest(workspace, name) {
380
510
  /**
381
511
  * Resolves a package's portable JavaScript binary from the target workspace.
382
512
  *
513
+ * @remarks
514
+ * A package publishes its binaries under keys of its own choosing, and that key is often not the
515
+ * package name: `typescript` publishes `tsc`. Name the key as `command` wherever the two differ.
516
+ * Default: the package name.
517
+ *
383
518
  * @param workspace - The target workspace root
384
- * @param name - The installed package name and binary key
519
+ * @param name - The installed package name
520
+ * @param command - The `bin` key to read. Default: `name`
385
521
  * @returns The absolute JavaScript entry named by the package's `bin` field
386
- * @throws When the package does not publish a binary under the requested name
522
+ * @throws When the package does not publish a binary under the requested key
387
523
  *
388
524
  * @example
389
525
  * ```ts
390
526
  * relativeWorkspaceFile(process.cwd(), resolveWorkspaceBinary(process.cwd(), 'oxlint'))
391
527
  * // 'node_modules/oxlint/bin/oxlint'
392
- * resolveWorkspaceBinary(process.cwd(), 'typescript')
393
- * // throws: typescript does not publish the typescript binary
528
+ * relativeWorkspaceFile(process.cwd(), resolveWorkspaceBinary(process.cwd(), 'typescript', 'tsc'))
529
+ * // 'node_modules/typescript/bin/tsc'
394
530
  * ```
395
531
  */
396
- function resolveWorkspaceBinary(workspace, name) {
532
+ function resolveWorkspaceBinary(workspace, name, command = name) {
397
533
  const manifest = readWorkspaceManifest(workspace, name);
398
534
  const bin = manifest.contents.bin;
399
535
  if (bin === void 0) throw new _src_core.ProbeError(`${name} does not publish a bin field`, {
@@ -402,13 +538,13 @@ function resolveWorkspaceBinary(workspace, name) {
402
538
  context: { name }
403
539
  });
404
540
  if (typeof bin === "string") return (0, node_path.resolve)(manifest.path, "..", bin);
405
- if (!(0, _orkestrel_contract.isRecord)(bin) || !(name in bin)) throw new _src_core.ProbeError(`${name} does not publish the ${name} binary`, {
541
+ if (!(0, _orkestrel_contract.isRecord)(bin) || !(command in bin)) throw new _src_core.ProbeError(`${name} does not publish the ${command} binary`, {
406
542
  origin: "workspace",
407
543
  code: "missing",
408
544
  context: { name }
409
545
  });
410
- const entry = bin[name];
411
- if (typeof entry !== "string") throw new _src_core.ProbeError(`${name} publishes an invalid ${name} binary`, {
546
+ const entry = bin[command];
547
+ if (typeof entry !== "string") throw new _src_core.ProbeError(`${name} publishes an invalid ${command} binary`, {
412
548
  origin: "workspace",
413
549
  code: "malformed",
414
550
  context: {
@@ -486,32 +622,66 @@ function inferDocumentLanguage(path) {
486
622
  return "typescript";
487
623
  }
488
624
  /**
489
- * Creates a fresh sibling identity while preserving a test's resolution directory.
625
+ * Builds the fresh sibling path a revision's file is written at, preserving the test's resolution
626
+ * directory.
490
627
  *
491
628
  * @param workspace - The target workspace root
492
629
  * @param path - The workspace-relative test path
493
630
  * @param revision - The fresh revision identity
494
- * @returns An absolute sibling file path
631
+ * @returns The absolute sibling path
495
632
  *
496
633
  * @example
497
634
  * ```ts
498
635
  * relativeWorkspaceFile(
499
636
  * '/srv/checkout',
500
- * createRevisionFile('/srv/checkout', 'tmp/probe/greeting.test.ts', '4821-9f0c'),
637
+ * buildRevisionPath('/srv/checkout', 'tmp/probe/greeting.test.ts', '4821-9f0c'),
501
638
  * )
502
639
  * // 'tmp/probe/greeting.test.probe-4821-9f0c.ts'
503
640
  * ```
504
641
  */
505
- function createRevisionFile(workspace, path, revision) {
642
+ function buildRevisionPath(workspace, path, revision) {
506
643
  const file = resolveWorkspaceFile(workspace, path);
507
644
  const extension = (0, node_path.extname)(file);
508
645
  return `${extension === "" ? file : file.slice(0, -extension.length)}.probe-${revision}${extension}`;
509
646
  }
510
647
  /**
648
+ * Filters one issue list to the distinct issues it carries, in the order they arrived.
649
+ *
650
+ * @remarks
651
+ * A stage that reads one candidate through more than one tool run reports that candidate's
652
+ * diagnostic once per run. Two issues are the same issue when their party, path, message, and range
653
+ * all agree, because those are every value an issue carries.
654
+ *
655
+ * @param issues - The issues to filter
656
+ * @returns The distinct issues, in the order the list carried them
657
+ *
658
+ * @example
659
+ * ```ts
660
+ * const issue: Issue = { origin: 'claimant', path: 'src/core/greeting.ts', message: 'not assignable' }
661
+ * filterUniqueIssues([issue, { ...issue }]).length // 1
662
+ * ```
663
+ */
664
+ function filterUniqueIssues(issues) {
665
+ const seen = /* @__PURE__ */ new Set();
666
+ const distinct = [];
667
+ for (const issue of issues) {
668
+ const key = JSON.stringify([
669
+ issue.origin,
670
+ issue.path,
671
+ issue.message,
672
+ issue.range
673
+ ]);
674
+ if (seen.has(key)) continue;
675
+ seen.add(key);
676
+ distinct.push(issue);
677
+ }
678
+ return distinct;
679
+ }
680
+ /**
511
681
  * Reports whether a path is a workspace module Vitest can cache.
512
682
  *
513
683
  * @param path - The candidate file path
514
- * @returns True for script, TypeScript, Vue, and JSON modules; false otherwise
684
+ * @returns True if the path is a script, TypeScript, Vue, or JSON module; false otherwise
515
685
  *
516
686
  * @example
517
687
  * ```ts
@@ -523,6 +693,75 @@ function matchesWorkspaceModule(path) {
523
693
  return /\.(?:[cm]?[jt]sx?|vue|json)$/.test(path);
524
694
  }
525
695
  /**
696
+ * Reports whether the host that wrote one file is still running.
697
+ *
698
+ * @remarks
699
+ * Signal 0 delivers nothing and reports reachability alone. A process this one may not signal
700
+ * reports `EPERM` and reads as running, and a non-positive identity names a process group rather
701
+ * than a process, so both answer true: the safe direction is to keep a file this package cannot
702
+ * account for.
703
+ *
704
+ * @param id - The process id a file's own revision names
705
+ * @returns True if that process is running or cannot be judged; false otherwise
706
+ *
707
+ * @example
708
+ * ```ts
709
+ * matchesLiveProcess(process.pid) // true
710
+ * matchesLiveProcess(0) // true
711
+ * ```
712
+ */
713
+ function matchesLiveProcess(id) {
714
+ if (!Number.isSafeInteger(id) || id <= 0) return true;
715
+ try {
716
+ process.kill(id, 0);
717
+ return true;
718
+ } catch (error) {
719
+ return readFaultCode(error) === "EPERM";
720
+ }
721
+ }
722
+ /**
723
+ * Collects every regular file a target workspace holds, skipping the trees no inspection reads.
724
+ *
725
+ * @remarks
726
+ * Skips a directory named `.git`, `dist`, or `node_modules` at any depth, and the directory
727
+ * {@link TYPE_MIRROR} names, which would otherwise carry this package's own mirror into the walk. A
728
+ * directory this host cannot list is skipped rather than raised, so one unreadable directory in a
729
+ * consumer's tree is not a reason to refuse the whole walk. A symbolic link is not carried, whether
730
+ * it names a file or a directory, so a file reached only through one is absent from the result; a
731
+ * `Dirent` that is neither a directory nor a regular file is skipped the same way.
732
+ *
733
+ * @param workspace - The target workspace root
734
+ * @returns The absolute path of every regular file the walk found
735
+ *
736
+ * @example
737
+ * ```ts
738
+ * collectWorkspaceFiles(process.cwd()).some((path) => path.endsWith('package.json')) // true
739
+ * ```
740
+ */
741
+ function collectWorkspaceFiles(workspace) {
742
+ const excluded = normalizePath(resolveWorkspaceFile(workspace, _src_core.TYPE_MIRROR));
743
+ const files = [];
744
+ const directories = [(0, node_path.resolve)(workspace)];
745
+ while (directories.length > 0) {
746
+ const directory = directories.pop();
747
+ if (directory === void 0) break;
748
+ let entries = [];
749
+ try {
750
+ entries = (0, node_fs.readdirSync)(directory, { withFileTypes: true });
751
+ } catch {}
752
+ for (const entry of entries) {
753
+ if (entry.name === ".git" || entry.name === "dist" || entry.name === "node_modules") continue;
754
+ const path = (0, node_path.join)(directory, entry.name);
755
+ if (entry.isDirectory()) {
756
+ if (normalizePath(path) !== excluded) directories.push(path);
757
+ continue;
758
+ }
759
+ if (entry.isFile()) files.push(path);
760
+ }
761
+ }
762
+ return files;
763
+ }
764
+ /**
526
765
  * Normalizes a caught or foreign error into readable text.
527
766
  *
528
767
  * @param value - The value to describe
@@ -541,9 +780,9 @@ function describeUnknown(value) {
541
780
  return String(value);
542
781
  }
543
782
  /**
544
- * Guards one resident-stage operation with the stage failure contract.
783
+ * Guards one stage operation with the stage failure contract.
545
784
  *
546
- * @param stage - The resident stage serving the operation
785
+ * @param stage - The stage serving the operation
547
786
  * @param operation - The operation to settle
548
787
  * @returns The operation's fulfilled value
549
788
  * @throws The original `ProbeError`, or an instrument-owned malformed failure retaining the cause
@@ -615,12 +854,12 @@ function findRefusedPaths(value) {
615
854
  * sorts every record's keys.
616
855
  *
617
856
  * @remarks
618
- * A parsed TypeScript project carries the absolute root it was parsed at, so the same commit
619
- * checked out at two paths produces two values that describe one configuration. Rewriting those
620
- * members makes the value portable and stops a host path leaking into anything derived from it. A
621
- * path that escapes the workspace is left as it stands, because rewriting it would name a file
622
- * outside the tree by a relative spelling. Key order is the parser's, not the configuration's, so
623
- * sorting removes a difference that means nothing.
857
+ * `tsc --showConfig` spells a path relative to the project file, so a printed record carries an
858
+ * absolute path only where a project itself declares one. Rewriting a contained absolute path to
859
+ * its relative form keeps one commit checked out at two roots reading to one digest. A path that
860
+ * escapes the workspace is left as it stands, because rewriting it would name a file outside the
861
+ * tree by a relative spelling. Key order is the compiler's own and it differs between the supported
862
+ * majors, so sorting removes a difference that means nothing.
624
863
  *
625
864
  * @param workspace - The target workspace root
626
865
  * @param value - The value to rewrite
@@ -638,7 +877,7 @@ function normalizeValue(workspace, value) {
638
877
  if (!(0, node_path.isAbsolute)(value)) return value;
639
878
  const path = (0, node_path.relative)(root, (0, node_path.resolve)(value));
640
879
  if (path === "") return ".";
641
- if (path === ".." || path.startsWith(`..${node_path.sep}`) || (0, node_path.isAbsolute)(path)) return value;
880
+ if (escapesRoot(root, value)) return value;
642
881
  return normalizePath(path);
643
882
  }
644
883
  if ((0, _orkestrel_contract.isArray)(value)) return value.map((entry) => normalizeValue(workspace, entry));
@@ -721,9 +960,66 @@ function releaseListeners(emitter, capture) {
721
960
  }
722
961
  }
723
962
  //#endregion
963
+ //#region src/server/parsers.ts
964
+ /**
965
+ * Parses the configuration one compiler run printed for a TypeScript project.
966
+ *
967
+ * @remarks
968
+ * Reads what `tsc --showConfig` writes: a JSON record carrying the resolved `compilerOptions`
969
+ * beside the project's own file selection. `compilerOptions` is carried unvalidated, because a
970
+ * caller digests it rather than reading a member out of it. `files` and `include` are read only
971
+ * where the printed record carries one whose entries are all strings, so a member the compiler
972
+ * printed in another shape reads as absent rather than as an empty selection. Text that is not a
973
+ * JSON record, including the diagnostic the compiler prints for a project it refuses, yields
974
+ * `undefined`.
975
+ *
976
+ * @param text - The compiler's printed configuration
977
+ * @returns The project's resolved configuration, or `undefined` when the text carries no record
978
+ *
979
+ * @example
980
+ * ```ts
981
+ * parseProjectConfig('{"compilerOptions":{"strict":true}}')?.compilerOptions // { strict: true }
982
+ * parseProjectConfig("error TS5058: The specified path does not exist.") // undefined
983
+ * ```
984
+ */
985
+ function parseProjectConfig(text) {
986
+ const parsed = (0, _orkestrel_contract.attempt)(() => JSON.parse(text));
987
+ if (!parsed.success || !(0, _orkestrel_contract.isRecord)(parsed.value)) return void 0;
988
+ const files = parsed.value.files;
989
+ const include = parsed.value.include;
990
+ return {
991
+ compilerOptions: parsed.value.compilerOptions,
992
+ ...(0, _orkestrel_contract.isArray)(files) && files.every((entry) => typeof entry === "string") ? { files } : {},
993
+ ...(0, _orkestrel_contract.isArray)(include) && include.every((entry) => typeof entry === "string") ? { include } : {}
994
+ };
995
+ }
996
+ /**
997
+ * Parses the process id one revision identity names.
998
+ *
999
+ * @remarks
1000
+ * Every file and directory this package writes into a target carries the writing host's process id
1001
+ * and a fresh UUID, joined by `-`. A sweep reads that id to decide whether the host that wrote the
1002
+ * name is gone. The whole identity must match, so a name carrying anything else yields `undefined`
1003
+ * and the sweep leaves what it found alone.
1004
+ *
1005
+ * @param revision - The revision identity a name carries
1006
+ * @returns The process id it names, or `undefined` when the text is not a revision identity
1007
+ *
1008
+ * @example
1009
+ * ```ts
1010
+ * parseRevisionOwner('4821-1b4e28ba-2fa1-11d2-883f-0016d3cca427') // 4821
1011
+ * parseRevisionOwner('4821-9f0c') // undefined
1012
+ * ```
1013
+ */
1014
+ function parseRevisionOwner(revision) {
1015
+ const owner = /^(\d+)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.exec(revision)?.[1];
1016
+ return owner === void 0 ? void 0 : Number.parseInt(owner, 10);
1017
+ }
1018
+ //#endregion
724
1019
  //#region src/server/Overlay.ts
725
1020
  /**
726
- * Holds the candidate drafts one inspection substitutes for the files a tool would read from disk.
1021
+ * Implements `OverlayInterface` over a private map from normalized absolute path to candidate text,
1022
+ * minting at construction the `revision` a resident tool caches its answers against.
727
1023
  *
728
1024
  * @remarks
729
1025
  * One overlay belongs to one inspection: the inspection creates it, records every candidate it
@@ -732,55 +1028,44 @@ function releaseListeners(emitter, capture) {
732
1028
  * is what makes a resident tool re-read a path this overlay holds, so two inspections that supply
733
1029
  * different text for one path never share a cached answer.
734
1030
  *
735
- * This overlay matches a lookup key the way the host its stage declares to its tool matches file
736
- * names. A host that resolves two spellings of one file name to one file leaves the tool holding
737
- * whichever spelling it met first — the disk one, where the file is on disk — and the tool asks
738
- * under that, so an overlay matching keys exactly answers nothing and the tool reads the committed
739
- * file instead. Mint the overlay with the sensitivity the stage declares, and one reading decides
740
- * both. The recorded spelling is what `paths` reports and what `covers` matches a directory
741
- * against, so a folded key never reaches the paths a tool is handed and containment stays an exact
742
- * comparison.
1031
+ * A lookup key matches a recorded path exactly, after both pass through `normalizePath`, so a tool
1032
+ * that reports its own paths with backslashes still reaches a candidate recorded with forward
1033
+ * slashes. Case is never folded: the recorded spelling is what `paths` reports, what `covers`
1034
+ * compares a directory against, and what `text` answers under. A stage whose tool resolves two
1035
+ * spellings of one file name to one file therefore reports the path its tool served rather than
1036
+ * matching the two spellings here.
743
1037
  *
744
1038
  * @example
745
1039
  * ```ts
746
1040
  * const overlay = new Overlay()
747
- * overlay.set('/srv/checkout/src/core/greeting.ts', "export const GREETING = 'hi'\n")
748
- * console.log(overlay.text('/srv/checkout/src/core/greeting.ts'))
1041
+ * const path = '/srv/checkout/src/core/factories.ts'
1042
+ * overlay.set(path, "export function createGreeting(): string {\n\treturn 'hi'\n}\n")
1043
+ * console.log(overlay.text(path))
749
1044
  * overlay.clear()
750
1045
  * ```
751
1046
  */
752
1047
  var Overlay = class {
753
1048
  #revision = (0, node_crypto.randomUUID)();
754
- #sensitive;
755
1049
  #candidates = /* @__PURE__ */ new Map();
756
- /**
757
- * Creates an empty candidate set under a fresh identity.
758
- *
759
- * @param options - Construction options
760
- */
761
- constructor(options = {}) {
762
- this.#sensitive = options.sensitive ?? true;
763
- }
764
1050
  get revision() {
765
1051
  return this.#revision;
766
1052
  }
767
1053
  get paths() {
768
- return [...this.#candidates.values()].map(([path]) => path);
1054
+ return [...this.#candidates.keys()];
769
1055
  }
770
1056
  /**
771
1057
  * Records one candidate's text against the absolute path it stands in for.
772
1058
  *
773
1059
  * @remarks
774
- * Recording a candidate under a path this overlay's own matching reads as one already recorded
775
- * replaces that candidate, the way one file holds one text.
1060
+ * Recording a candidate under a path this overlay already holds replaces that candidate, the way
1061
+ * one file holds one text.
776
1062
  *
777
1063
  * @param path - The absolute path the candidate replaces
778
1064
  * @param text - The candidate's full contents
779
1065
  * @returns Nothing
780
1066
  */
781
1067
  set(path, text) {
782
- const recorded = normalizePath(path);
783
- this.#candidates.set(this.#key(recorded), [recorded, text]);
1068
+ this.#candidates.set(normalizePath(path), text);
784
1069
  }
785
1070
  /**
786
1071
  * Reads the candidate text recorded for one absolute path.
@@ -789,7 +1074,7 @@ var Overlay = class {
789
1074
  * @returns The recorded text, or `undefined` when this overlay holds no candidate there
790
1075
  */
791
1076
  text(path) {
792
- return this.#candidates.get(this.#key(normalizePath(path)))?.[1];
1077
+ return this.#candidates.get(normalizePath(path));
793
1078
  }
794
1079
  /**
795
1080
  * Checks whether a candidate sits beneath one directory.
@@ -798,16 +1083,14 @@ var Overlay = class {
798
1083
  * The answer is derived from the paths the overlay holds rather than stored, so it stops being
799
1084
  * true exactly when the inspection that declared those candidates clears them. Both sides pass
800
1085
  * through `normalizePath` first, because a tool that normalizes its own paths asks about a
801
- * directory in a spelling the recorded path may not share. Case is never folded here: this is a
802
- * containment comparison, and it reads the recorded spelling whatever an overlay minted for a
803
- * case-folding host matches its lookup keys by.
1086
+ * directory in a spelling the recorded path may not share.
804
1087
  *
805
1088
  * @param directory - The absolute directory path to check
806
1089
  * @returns True if a candidate path sits beneath the directory; false otherwise
807
1090
  */
808
1091
  covers(directory) {
809
1092
  const base = `${normalizePath(directory).replace(/\/+$/, "")}/`;
810
- for (const [path] of this.#candidates.values()) if (path.startsWith(base)) return true;
1093
+ for (const path of this.#candidates.keys()) if (path.startsWith(base)) return true;
811
1094
  return false;
812
1095
  }
813
1096
  /**
@@ -818,17 +1101,14 @@ var Overlay = class {
818
1101
  clear() {
819
1102
  this.#candidates.clear();
820
1103
  }
821
- #key(path) {
822
- return this.#sensitive ? path : path.toLowerCase();
823
- }
824
1104
  };
825
1105
  //#endregion
826
1106
  //#region package.json
827
- var version = "0.0.11";
1107
+ var version = "0.0.13";
828
1108
  var peerDependencies = {
829
- "oxlint": "^1.77.0",
830
- "typescript": "^6.0.0",
831
- "vitest": "^4.1.0"
1109
+ "oxlint": "^1.82.0",
1110
+ "typescript": "^6.0.3",
1111
+ "vitest": "^4.1.11"
832
1112
  };
833
1113
  //#endregion
834
1114
  //#region src/server/stages/LintStage.ts
@@ -878,7 +1158,6 @@ var LintStage = class {
878
1158
  #closing;
879
1159
  #progress = 0;
880
1160
  #revision = 0;
881
- #destroyed = false;
882
1161
  /**
883
1162
  * Starts warming the target workspace's Oxlint language server.
884
1163
  *
@@ -900,12 +1179,11 @@ var LintStage = class {
900
1179
  }
901
1180
  destroy() {
902
1181
  if (this.#closing !== void 0) return this.#closing;
903
- this.#destroyed = true;
904
1182
  this.#closing = guardStage(this.stage, this.#destroy());
905
1183
  return this.#closing;
906
1184
  }
907
1185
  async #inspect(subject, options) {
908
- if (this.#destroyed) throw (0, _src_core.createDestroyedError)("lint stage");
1186
+ if (this.#closing !== void 0) throw (0, _src_core.createDestroyedError)("lint stage");
909
1187
  if (options === void 0) throw new _src_core.ProbeError("The lint stage inspects only under a bound its caller supplies", {
910
1188
  origin: "claimant",
911
1189
  code: "refused",
@@ -913,7 +1191,7 @@ var LintStage = class {
913
1191
  });
914
1192
  const started = performance.now();
915
1193
  const client = await this.#warmed();
916
- if (this.#destroyed) throw (0, _src_core.createDestroyedError)("lint stage");
1194
+ if (this.#closing !== void 0) throw (0, _src_core.createDestroyedError)("lint stage");
917
1195
  const issues = [];
918
1196
  for (const draft of [...subject.files, subject.test]) issues.push(...await this.#document(client, draft, options.signal));
919
1197
  return {
@@ -998,7 +1276,7 @@ var LintStage = class {
998
1276
  });
999
1277
  }
1000
1278
  #translate(error, path) {
1001
- if (this.#destroyed) return (0, _src_core.createDestroyedError)("lint stage");
1279
+ if (this.#closing !== void 0) return (0, _src_core.createDestroyedError)("lint stage");
1002
1280
  const ending = this.#ending;
1003
1281
  if (ending !== void 0) return this.#fault(`The Oxlint language server exited with ${ending}`, error, path);
1004
1282
  if (error instanceof Error) return error;
@@ -1053,6 +1331,11 @@ var LintStage = class {
1053
1331
  * The runtime overlay leaves bare specifier resolution to Vite: it replaces a bare import only
1054
1332
  * after Vite resolves that import to a covered file. The type stage overlays the declared test
1055
1333
  * path, while this stage executes a generated sibling and overlays only the candidate files.
1334
+ * This stage mints its overlay with the default exact-match sensitivity, because it declares no
1335
+ * file-name case sensitivity to Vite. A covered path whose spelling an importer wrote differs from
1336
+ * the recorded candidate path is therefore served by whatever answers first, and `#misses` reports
1337
+ * it as `The workspace configuration served this module before the runtime overlay` with origin
1338
+ * `workspace` rather than leaving it answered silently.
1056
1339
  *
1057
1340
  * Vite retains one unresolved URL for every specification path, so the stage replaces its whole
1058
1341
  * Vitest service after 64 specifications rather than deleting from each map that service owns. Any
@@ -1101,7 +1384,6 @@ var RuntimeStage = class {
1101
1384
  #specifications = 0;
1102
1385
  #closing;
1103
1386
  #progress = 0;
1104
- #destroyed = false;
1105
1387
  /**
1106
1388
  * Starts warming the target workspace's Vitest service.
1107
1389
  *
@@ -1109,7 +1391,7 @@ var RuntimeStage = class {
1109
1391
  */
1110
1392
  constructor(workspace = process.cwd()) {
1111
1393
  this.#workspace = workspace;
1112
- const vitest = loadWorkspaceModule(this.#workspace, "vitest/node");
1394
+ const vitest = loadWorkspaceVitest(this.#workspace);
1113
1395
  this.#store(this.#warm(vitest.createVitest));
1114
1396
  }
1115
1397
  get stage() {
@@ -1118,21 +1400,40 @@ var RuntimeStage = class {
1118
1400
  get progress() {
1119
1401
  return this.#progress;
1120
1402
  }
1403
+ /**
1404
+ * Inspects one case.
1405
+ *
1406
+ * @param subject - The candidate drafts and test to inspect
1407
+ * @returns One outcome for this stage
1408
+ * @throws When the workspace's own tool cannot start or has already been destroyed
1409
+ */
1121
1410
  inspect(subject) {
1122
1411
  return guardStage(this.stage, this.#inspect(subject));
1123
1412
  }
1413
+ /**
1414
+ * Tears down the resident tool or the mirror and releases its resources.
1415
+ *
1416
+ * @remarks
1417
+ * A stage abandons every inspection it holds rather than waiting behind one, so teardown never
1418
+ * waits for an inspection to return. An abandoned inspection rejects, either at the stage's own
1419
+ * guard or as the owned tool closes. Teardown is bounded whatever the stage's own tool does: a
1420
+ * tool that answers neither its warming exchange nor its ending is signalled and released at the
1421
+ * stage's own deadline. A coordinator replaces a stage whose worker no longer returns because
1422
+ * teardown neither waits for an inspection nor waits past that deadline.
1423
+ *
1424
+ * @returns A promise that settles after the resident tool or the mirror releases its resources
1425
+ */
1124
1426
  destroy() {
1125
1427
  if (this.#closing !== void 0) return this.#closing;
1126
- this.#destroyed = true;
1127
1428
  this.#closing = guardStage(this.stage, this.#destroy());
1128
1429
  return this.#closing;
1129
1430
  }
1130
1431
  async #inspect(subject) {
1131
- if (this.#destroyed) throw (0, _src_core.createDestroyedError)("runtime stage");
1432
+ if (this.#closing !== void 0) throw (0, _src_core.createDestroyedError)("runtime stage");
1132
1433
  const started = performance.now();
1133
1434
  const exitCode = process.exitCode;
1134
1435
  const vitest = await this.#runner();
1135
- if (this.#destroyed) throw (0, _src_core.createDestroyedError)("runtime stage");
1436
+ if (this.#closing !== void 0) throw (0, _src_core.createDestroyedError)("runtime stage");
1136
1437
  const project = this.#project(vitest, subject.test.path);
1137
1438
  if ("message" in project) return {
1138
1439
  stage: this.stage,
@@ -1274,7 +1575,7 @@ var RuntimeStage = class {
1274
1575
  return {
1275
1576
  ...config,
1276
1577
  plugins: [...config.plugins ?? [], {
1277
- name: "orkestrel-runtime-overlay",
1578
+ name: _src_core.RUNTIME_PLUGIN,
1278
1579
  enforce: "pre",
1279
1580
  resolveId: this.#resolve.bind(this),
1280
1581
  load: this.#load.bind(this)
@@ -1316,7 +1617,7 @@ var RuntimeStage = class {
1316
1617
  let generated;
1317
1618
  const outcome = (0, _orkestrel_contract.attempt)(() => {
1318
1619
  const revision = `${process.pid}-${(0, node_crypto.randomUUID)()}`;
1319
- const file = createRevisionFile(this.#workspace, test.path, revision);
1620
+ const file = buildRevisionPath(this.#workspace, test.path, revision);
1320
1621
  generated = file;
1321
1622
  const target = (0, node_path.relative)(this.#workspace, file);
1322
1623
  resolveWorkspaceFile(this.#workspace, target, true);
@@ -1374,7 +1675,7 @@ var RuntimeStage = class {
1374
1675
  path
1375
1676
  }
1376
1677
  });
1377
- if (!project.vite.config.plugins.some((plugin) => plugin.name === "orkestrel-runtime-overlay")) return {
1678
+ if (!project.vite.config.plugins.some((plugin) => plugin.name === _src_core.RUNTIME_PLUGIN)) return {
1378
1679
  origin: "workspace",
1379
1680
  path,
1380
1681
  message: `The runtime stage cannot instrument the string-declared Vitest project ${name} because its configuration carries no runtime overlay plugin`
@@ -1463,15 +1764,15 @@ var RuntimeStage = class {
1463
1764
  }
1464
1765
  #runner() {
1465
1766
  const current = this.#vitest;
1466
- if (current !== void 0 && this.#specifications < 64) return current;
1767
+ if (current !== void 0 && this.#specifications < _src_core.PROBE_SPECIFICATIONS) return current;
1467
1768
  if (current !== void 0) return this.#store(this.#replace(current));
1468
- const runner = loadWorkspaceModule(this.#workspace, "vitest/node");
1469
- return this.#store(this.#specifications < 64 ? this.#warm(runner.createVitest) : this.#replace(void 0, runner.createVitest));
1769
+ const runner = loadWorkspaceVitest(this.#workspace);
1770
+ return this.#store(this.#specifications < _src_core.PROBE_SPECIFICATIONS ? this.#warm(runner.createVitest) : this.#replace(void 0, runner.createVitest));
1470
1771
  }
1471
1772
  async #replace(current, create) {
1472
1773
  if (current !== void 0) await (await current).close();
1473
- if (this.#destroyed) throw (0, _src_core.createDestroyedError)("runtime stage");
1474
- const runner = create ?? loadWorkspaceModule(this.#workspace, "vitest/node").createVitest;
1774
+ if (this.#closing !== void 0) throw (0, _src_core.createDestroyedError)("runtime stage");
1775
+ const runner = create ?? loadWorkspaceVitest(this.#workspace).createVitest;
1475
1776
  const replacement = await this.#warm(runner);
1476
1777
  this.#specifications = 0;
1477
1778
  return replacement;
@@ -1515,7 +1816,7 @@ var RuntimeStage = class {
1515
1816
  }
1516
1817
  #snapshot() {
1517
1818
  const modules = /* @__PURE__ */ new Map();
1518
- for (const path of this.#walk()) {
1819
+ for (const path of collectWorkspaceFiles(this.#workspace)) {
1519
1820
  if (!matchesWorkspaceModule(path)) continue;
1520
1821
  try {
1521
1822
  const digest = (0, node_crypto.createHash)("sha256").update((0, node_fs.readFileSync)(path)).digest("hex");
@@ -1526,12 +1827,12 @@ var RuntimeStage = class {
1526
1827
  return modules;
1527
1828
  }
1528
1829
  #sweep() {
1529
- for (const path of this.#walk()) {
1830
+ for (const path of collectWorkspaceFiles(this.#workspace)) {
1530
1831
  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);
1531
1832
  const revision = match?.[1];
1532
1833
  const owner = match?.[2];
1533
1834
  if (revision === void 0 || owner === void 0) continue;
1534
- if (this.#alive(Number.parseInt(owner, 10))) continue;
1835
+ if (matchesLiveProcess(Number.parseInt(owner, 10))) continue;
1535
1836
  if (!this.#owned(path, revision)) continue;
1536
1837
  try {
1537
1838
  (0, node_fs.unlinkSync)(resolveWorkspaceFile(this.#workspace, (0, node_path.relative)(this.#workspace, path), true));
@@ -1542,35 +1843,6 @@ var RuntimeStage = class {
1542
1843
  const outcome = (0, _orkestrel_contract.attempt)(() => (0, node_fs.readFileSync)(path, "utf8"));
1543
1844
  return outcome.success && (0, _src_core.matchesSpecification)(outcome.value, revision);
1544
1845
  }
1545
- #alive(id) {
1546
- if (!Number.isSafeInteger(id) || id <= 0) return true;
1547
- try {
1548
- process.kill(id, 0);
1549
- return true;
1550
- } catch (error) {
1551
- return error instanceof Error && "code" in error && error.code === "EPERM";
1552
- }
1553
- }
1554
- *#walk() {
1555
- const directories = [(0, node_path.resolve)(this.#workspace)];
1556
- while (directories.length > 0) {
1557
- const directory = directories.pop();
1558
- if (directory === void 0) break;
1559
- let entries = [];
1560
- try {
1561
- entries = (0, node_fs.readdirSync)(directory, { withFileTypes: true });
1562
- } catch {}
1563
- for (const entry of entries) {
1564
- if (entry.name === ".git" || entry.name === "dist" || entry.name === "node_modules") continue;
1565
- const path = (0, node_path.join)(directory, entry.name);
1566
- if (entry.isDirectory()) {
1567
- directories.push(path);
1568
- continue;
1569
- }
1570
- if (entry.isFile()) yield path;
1571
- }
1572
- }
1573
- }
1574
1846
  #issues(result, file, original) {
1575
1847
  const specification = this.#real(file);
1576
1848
  const issues = [];
@@ -1686,21 +1958,32 @@ var RuntimeStage = class {
1686
1958
  //#endregion
1687
1959
  //#region src/server/stages/TypeStage.ts
1688
1960
  /**
1689
- * Inspects TypeScript source through resident language services from the target workspace.
1961
+ * Inspects TypeScript source by running the target workspace's own compiler over a mirror of it.
1690
1962
  *
1691
1963
  * @remarks
1692
- * Construction starts loading the workspace's compiler and warming one service per project the
1693
- * workspace declares. A candidate draft is checked against the project a call names, or
1694
- * against its own scoped environment project when a call names none, while the test uses the root
1695
- * project. Disk snapshots use their modification time as the service version so dependency edits
1696
- * cannot leave a warm answer stale. Candidate text lives in an overlay the inspection owns, and
1697
- * the language-service host answers existence, reads, and versions from it, so a candidate that
1698
- * exists only as text is importable and a candidate that shadows a disk file is checked as the
1699
- * text the case supplied. A project outside the declared set holds one recycled slot, so a caller
1700
- * varying the project cannot grow the resident set. A language service checks one candidate
1701
- * synchronously, so the stage hands the host's event loop back at each candidate boundary: a
1702
- * caller's deadline is answered within one candidate's check rather than after the whole
1703
- * inspection, and an inspection abandoned at that deadline stops at the next boundary.
1964
+ * The compiler is a process this stage spawns rather than a module it loads, so nothing here holds
1965
+ * a resident program and the host's event loop is free for the whole check. Construction sweeps a
1966
+ * mirror an earlier host left behind, copies the workspace into a fresh one under
1967
+ * {@link TYPE_MIRROR}, and builds each declared project's incremental state there, so the first
1968
+ * inspection is warm.
1969
+ *
1970
+ * Every inspection refreshes the mirror by content digest, writes each candidate draft and the test
1971
+ * at its mirrored declared path, and runs `tsc --noEmit --pretty false` once per distinct selected
1972
+ * project. A draft therefore shadows the file it replaces: a consumer of that path is checked
1973
+ * against the draft's text, and a draft importing a sibling draft resolves to the sibling draft.
1974
+ * Nothing is written outside `tmp/`, and the workspace's own copy of a drafted file never moves.
1975
+ *
1976
+ * Each run reads a scratch project this stage writes beside the mirrored project it extends, so
1977
+ * every relative path the project declares — its own `rootDir`, `include`, `paths`, and the
1978
+ * projects it extends — resolves inside the mirror without being rewritten. The scratch adds the
1979
+ * drafts as `files`, which the project's own `exclude` does not reach, and its incremental state
1980
+ * lives in the mirror so reuse survives the refresh.
1981
+ *
1982
+ * The diagnostics decide the outcome, never the exit code, which the supported compiler majors
1983
+ * disagree on. A diagnostic naming a project file, and one naming no file at all, is the target
1984
+ * tree's own configuration fault and raises rather than reporting, unless the `.json` file is one
1985
+ * the claim itself drafted; every other diagnostic is a claimant issue at the point the compiler
1986
+ * reported, which is the extent the plain-text output carries.
1704
1987
  *
1705
1988
  * @example
1706
1989
  * ```ts
@@ -1711,27 +1994,27 @@ var RuntimeStage = class {
1711
1994
  */
1712
1995
  var TypeStage = class {
1713
1996
  #workspace;
1714
- #typescript;
1715
- #services = /* @__PURE__ */ new Map();
1716
- #resident = /* @__PURE__ */ new Set();
1717
- #options = /* @__PURE__ */ new Map();
1718
- #files = /* @__PURE__ */ new Map();
1719
- #diagnostics = /* @__PURE__ */ new Map();
1720
- #overlay = new Overlay();
1721
- #recycled;
1997
+ #compiler;
1998
+ #revision = `${process.pid}-${(0, node_crypto.randomUUID)()}`;
1999
+ #mirror;
2000
+ #configs = /* @__PURE__ */ new Map();
2001
+ #mirrored = /* @__PURE__ */ new Map();
2002
+ #drafts = /* @__PURE__ */ new Set();
2003
+ #children = /* @__PURE__ */ new Set();
2004
+ #warming;
1722
2005
  #closing;
1723
2006
  #progress = 0;
1724
- #destroyed = false;
1725
2007
  /**
1726
- * Starts warming the target workspace's TypeScript service.
2008
+ * Resolves the target workspace's compiler and starts warming its mirror.
1727
2009
  *
1728
2010
  * @param workspace - The target workspace root. Default: the current working directory
1729
2011
  */
1730
2012
  constructor(workspace = process.cwd()) {
1731
2013
  this.#workspace = workspace;
1732
- const typescript = loadWorkspaceModule(this.#workspace, "typescript");
1733
- this.#typescript = this.#warm(typescript);
1734
- this.#typescript.catch(() => {});
2014
+ this.#compiler = resolveWorkspaceBinary(workspace, "typescript", "tsc");
2015
+ this.#mirror = resolveWorkspaceFile(workspace, `${_src_core.TYPE_MIRROR}/${this.#revision}`);
2016
+ this.#warming = this.#warm();
2017
+ this.#warming.catch(() => {});
1735
2018
  }
1736
2019
  get stage() {
1737
2020
  return "type";
@@ -1751,7 +2034,8 @@ var TypeStage = class {
1751
2034
  * @param project - The workspace-relative TypeScript project the candidate drafts are checked
1752
2035
  * against. Default: the scoped project each candidate path infers
1753
2036
  * @returns One outcome for this stage
1754
- * @throws When the resident compiler cannot start or the stage has already been destroyed
2037
+ * @throws When the workspace refuses the mirror, when a project the run reads is malformed, or
2038
+ * when the stage has already been destroyed
1755
2039
  */
1756
2040
  inspect(subject, project) {
1757
2041
  return guardStage(this.stage, this.#inspect(subject, project));
@@ -1760,101 +2044,95 @@ var TypeStage = class {
1760
2044
  * Resolves one project to the path and digest this stage applies for it.
1761
2045
  *
1762
2046
  * @remarks
1763
- * Reads the parse this stage itself applies, filling its cache when the project is not already
1764
- * resident, so the reported digest is the configuration the inspection is judged under rather
1765
- * than a second parse a caller ran. The returned record is a value copy, so a later eviction
1766
- * does not move it.
2047
+ * Reads the configuration the compiler itself prints for the project as the mirror holds it,
2048
+ * refreshed from the workspace first on a cache miss, so the digest names the configuration the
2049
+ * check applies. The reading is cached per project for the life of the stage, so a draft written
2050
+ * afterward cannot move it.
1767
2051
  *
1768
2052
  * @param project - The workspace-relative TypeScript project to resolve
1769
2053
  * @returns The resolved workspace-relative path and the digest of its compiler options
1770
- * @throws When the project escapes the workspace, cannot be parsed, or the stage has already
1771
- * been destroyed
2054
+ * @throws When the project escapes the workspace, when the compiler refuses it, or when the
2055
+ * stage has already been destroyed
1772
2056
  */
1773
2057
  resolve(project) {
1774
2058
  return guardStage(this.stage, this.#resolve(project));
1775
2059
  }
1776
2060
  destroy() {
1777
2061
  if (this.#closing !== void 0) return this.#closing;
1778
- this.#destroyed = true;
1779
2062
  this.#closing = guardStage(this.stage, this.#destroy());
1780
2063
  return this.#closing;
1781
2064
  }
1782
2065
  async #inspect(subject, project) {
1783
- if (this.#destroyed) throw (0, _src_core.createDestroyedError)("type stage");
2066
+ this.#refuseDestroyed();
1784
2067
  const started = performance.now();
1785
- const typescript = await this.#typescript;
1786
- if (this.#destroyed) throw (0, _src_core.createDestroyedError)("type stage");
1787
- const selections = subject.files.map((draft) => ({
2068
+ await this.#warming;
2069
+ this.#refuseDestroyed();
2070
+ const test = resolveWorkspaceFile(this.#workspace, subject.test.path);
2071
+ const resolved = subject.files.map((draft) => ({
1788
2072
  draft,
1789
2073
  path: resolveWorkspaceFile(this.#workspace, draft.path)
1790
- })).map((candidate) => ({
1791
- draft: candidate.draft,
1792
- project: project ?? inferTypeProject(relativeWorkspaceFile(this.#workspace, candidate.path))
1793
2074
  }));
1794
- const root = this.#service(typescript, "tsconfig.json");
1795
- this.#configure(root, "tsconfig.json");
1796
- await this.#unblock();
1797
- for (const selection of selections) {
1798
- this.#configure(this.#service(typescript, selection.project), selection.project);
1799
- await this.#unblock();
2075
+ const groups = /* @__PURE__ */ new Map([[this.#contain("tsconfig.json"), [subject.test]]]);
2076
+ for (const candidate of resolved) {
2077
+ const selected = this.#contain(project ?? inferTypeProject(relativeWorkspaceFile(this.#workspace, candidate.path)));
2078
+ const drafts = groups.get(selected);
2079
+ if (drafts === void 0) groups.set(selected, [candidate.draft]);
2080
+ else drafts.push(candidate.draft);
1800
2081
  }
1801
- const overlay = new Overlay({ sensitive: this.#caseSensitive(typescript) });
1802
- this.#overlay = overlay;
2082
+ this.#progress += 1;
2083
+ for (const selected of groups.keys()) await this.#configure(selected);
2084
+ this.#refuseDestroyed();
1803
2085
  try {
1804
- this.#record(subject.test, overlay);
1805
- for (const draft of subject.files) this.#record(draft, overlay);
1806
- this.#progress += 1;
2086
+ this.#refresh();
2087
+ this.#drafts.add(this.#place(subject.test.text, test));
2088
+ for (const candidate of resolved) this.#drafts.add(this.#place(candidate.draft.text, candidate.path));
1807
2089
  const issues = [];
1808
- const projects = /* @__PURE__ */ new Set();
1809
- issues.push(...this.#issues(typescript, root, subject.test, "tsconfig.json", false, true));
1810
- projects.add("tsconfig.json");
1811
- await this.#unblock();
1812
- for (const selection of selections) {
1813
- const draft = selection.draft;
1814
- const selected = selection.project;
1815
- const service = this.#service(typescript, selected);
1816
- issues.push(...this.#issues(typescript, service, draft, selected, project !== void 0, !projects.has(selected)));
1817
- projects.add(selected);
1818
- await this.#unblock();
2090
+ for (const [selected, drafts] of groups) {
2091
+ this.#refuseDestroyed();
2092
+ issues.push(...await this.#check(selected, drafts));
1819
2093
  }
1820
2094
  return {
1821
2095
  stage: this.stage,
1822
2096
  elapsed: Math.round(performance.now() - started),
1823
- issues
2097
+ issues: filterUniqueIssues(issues)
1824
2098
  };
1825
2099
  } finally {
1826
- overlay.clear();
2100
+ this.#release();
1827
2101
  }
1828
2102
  }
1829
2103
  async #resolve(project) {
1830
- if (this.#destroyed) throw (0, _src_core.createDestroyedError)("type stage");
1831
- const typescript = await this.#typescript;
1832
- if (this.#destroyed) throw (0, _src_core.createDestroyedError)("type stage");
1833
- const resolved = resolveWorkspaceFile(this.#workspace, project);
2104
+ this.#refuseDestroyed();
2105
+ const contained = this.#contain(project);
1834
2106
  this.#progress += 1;
1835
- this.#service(typescript, project);
1836
- await this.#unblock();
2107
+ const config = await this.#configure(contained);
1837
2108
  return {
1838
- path: relativeWorkspaceFile(this.#workspace, resolved),
1839
- digest: computeDigest(this.#workspace, this.#options.get(resolved) ?? {})
2109
+ path: contained,
2110
+ digest: computeDigest(this.#workspace, config.compilerOptions)
1840
2111
  };
1841
2112
  }
1842
2113
  async #destroy() {
1843
- await this.#typescript.catch(() => void 0);
1844
- for (const service of this.#services.values()) service.dispose();
1845
- this.#services.clear();
1846
- this.#resident.clear();
1847
- this.#recycled = void 0;
1848
- this.#options.clear();
1849
- this.#files.clear();
1850
- this.#diagnostics.clear();
1851
- }
1852
- async #warm(typescript) {
1853
- for (const project of this.#projects()) {
1854
- this.#resident.add(resolveWorkspaceFile(this.#workspace, project));
1855
- this.#service(typescript, project);
1856
- }
1857
- return typescript;
2114
+ const children = [...this.#children];
2115
+ this.#children.clear();
2116
+ for (const child of children) this.#terminate(child);
2117
+ await this.#warming.catch(() => void 0);
2118
+ try {
2119
+ (0, node_fs.rmSync)(this.#mirror, {
2120
+ recursive: true,
2121
+ force: true,
2122
+ maxRetries: 5,
2123
+ retryDelay: 20
2124
+ });
2125
+ } catch {}
2126
+ this.#configs.clear();
2127
+ this.#mirrored.clear();
2128
+ this.#drafts.clear();
2129
+ }
2130
+ async #warm() {
2131
+ this.#sweep();
2132
+ this.#createMirror();
2133
+ this.#refresh();
2134
+ this.#refuseDestroyed();
2135
+ await Promise.all(this.#projects().map((project) => this.#check(project, [])));
1858
2136
  }
1859
2137
  #projects() {
1860
2138
  const projects = ["tsconfig.json"];
@@ -1870,162 +2148,291 @@ var TypeStage = class {
1870
2148
  }
1871
2149
  return projects;
1872
2150
  }
1873
- async #unblock() {
1874
- await (0, node_timers_promises.setTimeout)(0);
1875
- if (this.#destroyed) throw (0, _src_core.createDestroyedError)("type stage");
2151
+ #refuseDestroyed() {
2152
+ if (this.#closing !== void 0) throw (0, _src_core.createDestroyedError)("type stage");
1876
2153
  }
1877
- #record(draft, overlay) {
1878
- overlay.set(resolveWorkspaceFile(this.#workspace, draft.path), draft.text);
1879
- }
1880
- #service(typescript, project) {
1881
- const path = resolveWorkspaceFile(this.#workspace, project);
1882
- const existing = this.#services.get(path);
1883
- if (existing !== void 0) return existing;
1884
- const spelling = normalizePath(path);
1885
- const config = typescript.readConfigFile(spelling, typescript.sys.readFile);
1886
- if (config.error !== void 0) throw new _src_core.ProbeError(this.#translate(typescript, config.error.messageText), {
1887
- origin: "workspace",
1888
- code: "malformed",
1889
- context: {
1890
- stage: this.stage,
1891
- project
1892
- }
2154
+ #createMirror() {
2155
+ const marker = resolveWorkspaceFile(this.#workspace, `${_src_core.TYPE_MIRROR}/${this.#revision}/.probe/mirror.txt`, true);
2156
+ (0, node_fs.mkdirSync)((0, node_path.dirname)(marker), { recursive: true });
2157
+ (0, node_fs.writeFileSync)(marker, (0, _src_core.formatSpecification)("", this.#revision), {
2158
+ encoding: "utf8",
2159
+ flag: "wx"
1893
2160
  });
1894
- const parsed = typescript.parseJsonConfigFileContent(config.config, typescript.sys, (0, node_path.dirname)(spelling), void 0, spelling);
1895
- if (parsed.errors.length > 0) throw new _src_core.ProbeError(this.#translate(typescript, parsed.errors[0]?.messageText), {
1896
- origin: "workspace",
1897
- code: "malformed",
1898
- context: {
1899
- stage: this.stage,
1900
- project
1901
- }
1902
- });
1903
- this.#options.set(path, parsed.options);
1904
- this.#files.set(path, parsed.fileNames);
1905
- const host = {
1906
- getCompilationSettings: this.#compilationSettings.bind(this, path),
1907
- getScriptFileNames: this.#scriptFiles.bind(this, path),
1908
- getScriptVersion: this.#version.bind(this),
1909
- getScriptSnapshot: this.#snapshot.bind(this, typescript),
1910
- getCurrentDirectory: this.#directory.bind(this),
1911
- getDefaultLibFileName: this.#defaultLibrary.bind(this, typescript),
1912
- fileExists: this.#fileExists.bind(this, typescript),
1913
- readFile: this.#readFile.bind(this, typescript),
1914
- readDirectory: typescript.sys.readDirectory,
1915
- directoryExists: this.#directoryExists.bind(this, typescript),
1916
- getDirectories: typescript.sys.getDirectories,
1917
- useCaseSensitiveFileNames: this.#caseSensitive.bind(this, typescript),
1918
- getNewLine: this.#newline.bind(this, typescript)
1919
- };
1920
- const service = typescript.createLanguageService(host);
1921
- this.#services.set(path, service);
1922
- if (!this.#resident.has(path)) this.#recycle(path);
1923
- return service;
1924
- }
1925
- #compilationSettings(path) {
1926
- return this.#options.get(path) ?? {};
1927
2161
  }
1928
- #scriptFiles(path) {
1929
- return [...this.#files.get(path) ?? [], ...this.#overlay.paths];
1930
- }
1931
- #directory() {
1932
- return this.#workspace;
1933
- }
1934
- #defaultLibrary(typescript, options) {
1935
- return typescript.getDefaultLibFilePath(options);
1936
- }
1937
- #fileExists(typescript, file) {
1938
- return this.#overlay.text(file) !== void 0 || typescript.sys.fileExists(file);
1939
- }
1940
- #readFile(typescript, file) {
1941
- return this.#overlay.text(file) ?? typescript.sys.readFile(file);
1942
- }
1943
- #directoryExists(typescript, directory) {
1944
- return typescript.sys.directoryExists(directory) || this.#overlay.covers(directory);
1945
- }
1946
- #caseSensitive(typescript) {
1947
- return typescript.sys.useCaseSensitiveFileNames;
2162
+ #sweep() {
2163
+ const root = resolveWorkspaceFile(this.#workspace, _src_core.TYPE_MIRROR);
2164
+ let entries = [];
2165
+ try {
2166
+ entries = (0, node_fs.readdirSync)(root, { withFileTypes: true });
2167
+ } catch {
2168
+ return;
2169
+ }
2170
+ for (const entry of entries) {
2171
+ if (!entry.isDirectory()) continue;
2172
+ const owner = parseRevisionOwner(entry.name);
2173
+ if (owner === void 0 || matchesLiveProcess(owner)) continue;
2174
+ const marker = (0, _orkestrel_contract.attempt)(() => (0, node_fs.readFileSync)((0, node_path.join)(root, entry.name, ".probe", "mirror.txt"), "utf8"));
2175
+ if (!marker.success || !(0, _src_core.matchesSpecification)(marker.value, entry.name)) continue;
2176
+ try {
2177
+ (0, node_fs.rmSync)((0, node_path.join)(root, entry.name), {
2178
+ recursive: true,
2179
+ force: true,
2180
+ maxRetries: 5,
2181
+ retryDelay: 20
2182
+ });
2183
+ } catch {}
2184
+ }
1948
2185
  }
1949
- #newline(typescript) {
1950
- return typescript.sys.newLine;
2186
+ #refresh() {
2187
+ const present = /* @__PURE__ */ new Set();
2188
+ for (const path of collectWorkspaceFiles(this.#workspace)) {
2189
+ const contained = relativeWorkspaceFile(this.#workspace, path);
2190
+ present.add(contained);
2191
+ const reading = (0, _orkestrel_contract.attempt)(() => (0, node_fs.readFileSync)(path));
2192
+ if (!reading.success) continue;
2193
+ const digest = (0, node_crypto.createHash)("sha256").update(reading.value).digest("hex");
2194
+ if (this.#mirrored.get(contained) === digest) continue;
2195
+ this.#place(reading.value, path);
2196
+ this.#mirrored.set(contained, digest);
2197
+ }
2198
+ for (const contained of [...this.#mirrored.keys()]) {
2199
+ if (present.has(contained)) continue;
2200
+ this.#remove(contained);
2201
+ this.#mirrored.delete(contained);
2202
+ }
1951
2203
  }
1952
- #translate(typescript, message) {
1953
- return relativeWorkspaceMessage(this.#workspace, typescript.flattenDiagnosticMessageText(message, "\n"));
2204
+ #release() {
2205
+ for (const contained of this.#drafts) {
2206
+ this.#remove(contained);
2207
+ this.#mirrored.delete(contained);
2208
+ }
2209
+ this.#drafts.clear();
2210
+ }
2211
+ #place(text, path) {
2212
+ const contained = relativeWorkspaceFile(this.#workspace, path);
2213
+ const target = this.#mirrorPath(contained);
2214
+ if ((0, _orkestrel_contract.attempt)(() => {
2215
+ (0, node_fs.mkdirSync)((0, node_path.dirname)(target), { recursive: true });
2216
+ (0, node_fs.writeFileSync)(target, text);
2217
+ }).success) return contained;
2218
+ this.#displace(target);
2219
+ (0, node_fs.mkdirSync)((0, node_path.dirname)(target), { recursive: true });
2220
+ (0, node_fs.writeFileSync)(target, text);
2221
+ return contained;
2222
+ }
2223
+ #displace(target) {
2224
+ const ancestors = [];
2225
+ let directory = (0, node_path.dirname)(target);
2226
+ while (directory !== this.#mirror && directory !== (0, node_path.dirname)(directory)) {
2227
+ ancestors.unshift(directory);
2228
+ directory = (0, node_path.dirname)(directory);
2229
+ }
2230
+ for (const ancestor of [...ancestors, target]) {
2231
+ const reading = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(ancestor));
2232
+ if (!reading.success) continue;
2233
+ if (ancestor !== target && reading.value.isDirectory()) continue;
2234
+ if (ancestor === target && !reading.value.isDirectory()) continue;
2235
+ (0, node_fs.rmSync)(ancestor, {
2236
+ recursive: true,
2237
+ force: true,
2238
+ maxRetries: 5,
2239
+ retryDelay: 20
2240
+ });
2241
+ this.#mirrored.delete(relativeWorkspaceFile(this.#mirror, ancestor));
2242
+ }
1954
2243
  }
1955
- #configure(service, project) {
1956
- const path = resolveWorkspaceFile(this.#workspace, project);
1957
- if (this.#diagnostics.has(path)) return;
1958
- this.#diagnostics.set(path, service.getCompilerOptionsDiagnostics());
2244
+ #mirrorPath(contained) {
2245
+ return (0, node_path.join)(this.#mirror, contained);
1959
2246
  }
1960
- #recycle(path) {
1961
- const previous = this.#recycled;
1962
- this.#recycled = path;
1963
- if (previous === void 0 || previous === path) return;
1964
- this.#services.get(previous)?.dispose();
1965
- this.#services.delete(previous);
1966
- this.#options.delete(previous);
1967
- this.#files.delete(previous);
1968
- this.#diagnostics.delete(previous);
2247
+ #contain(project) {
2248
+ return relativeWorkspaceFile(this.#workspace, resolveWorkspaceFile(this.#workspace, project));
1969
2249
  }
1970
- #version(file) {
1971
- if (this.#overlay.text(file) !== void 0) return `virtual:${this.#overlay.revision}`;
2250
+ #remove(contained) {
1972
2251
  try {
1973
- return `disk:${(0, node_fs.statSync)(file).mtimeMs}`;
1974
- } catch {
1975
- return "missing";
1976
- }
2252
+ (0, node_fs.rmSync)(this.#mirrorPath(contained), {
2253
+ force: true,
2254
+ maxRetries: 5,
2255
+ retryDelay: 20
2256
+ });
2257
+ } catch {}
1977
2258
  }
1978
- #snapshot(typescript, file) {
1979
- const text = this.#overlay.text(file) ?? typescript.sys.readFile(file);
1980
- return text === void 0 ? void 0 : typescript.ScriptSnapshot.fromString(text);
1981
- }
1982
- #issues(typescript, service, draft, project, selected, configure) {
1983
- const path = resolveWorkspaceFile(this.#workspace, draft.path);
1984
- const configuration = resolveWorkspaceFile(this.#workspace, project);
1985
- return [
1986
- ...configure ? this.#diagnostics.get(configuration) ?? [] : [],
1987
- ...service.getSyntacticDiagnostics(path),
1988
- ...service.getSemanticDiagnostics(path)
1989
- ].map((diagnostic) => this.#issue(typescript, diagnostic, project, selected));
1990
- }
1991
- #issue(typescript, diagnostic, project, selected) {
1992
- const message = this.#translate(typescript, diagnostic.messageText);
1993
- if (diagnostic.file === void 0) {
1994
- if (selected) throw new _src_core.ProbeError(message, {
1995
- origin: "claimant",
1996
- code: "refused",
2259
+ async #configure(project) {
2260
+ const contained = this.#contain(project);
2261
+ const existing = this.#configs.get(contained);
2262
+ if (existing !== void 0) return existing;
2263
+ this.#refresh();
2264
+ const execution = await this.#spawn([
2265
+ "--showConfig",
2266
+ "-p",
2267
+ contained
2268
+ ], this.#mirror);
2269
+ this.#refuseDestroyed();
2270
+ const config = parseProjectConfig(execution.stdout);
2271
+ if (config === void 0) throw this.#fault(execution, contained);
2272
+ this.#configs.set(contained, config);
2273
+ return config;
2274
+ }
2275
+ async #check(project, drafts) {
2276
+ const config = await this.#configure(project);
2277
+ const scratch = this.#scratch(this.#contain(project), config, drafts);
2278
+ const execution = await this.#spawn([
2279
+ "--noEmit",
2280
+ "--pretty",
2281
+ "false",
2282
+ "-p",
2283
+ `./${scratch}`
2284
+ ], this.#mirror);
2285
+ this.#refuseDestroyed();
2286
+ const diagnostics = scanDiagnostics(execution.stdout);
2287
+ if (diagnostics.length === 0 && execution.status !== 0) {
2288
+ const stderr = execution.stderr.trim();
2289
+ const message = stderr !== "" ? stderr : execution.status === void 0 ? "The compiler reported no diagnostic and was ended by a signal" : `The compiler reported no diagnostic and exited ${execution.status}`;
2290
+ throw new _src_core.ProbeError(this.#translate(message), {
2291
+ origin: "instrument",
2292
+ code: "malformed",
1997
2293
  context: {
1998
2294
  stage: this.stage,
1999
2295
  project
2000
2296
  }
2001
2297
  });
2002
- return {
2003
- origin: "workspace",
2004
- path: project,
2005
- message
2006
- };
2007
2298
  }
2008
- const path = relativeWorkspaceFile(this.#workspace, diagnostic.file.fileName);
2009
- if (diagnostic.start === void 0) return {
2010
- origin: "claimant",
2011
- path,
2012
- message
2299
+ return this.#issues(diagnostics, project);
2300
+ }
2301
+ #scratch(project, config, drafts) {
2302
+ const directory = (0, node_path.dirname)(project);
2303
+ const stem = `${(0, node_path.basename)(project, (0, node_path.extname)(project))}.probe.json`;
2304
+ const contained = directory === "." ? stem : `${directory}/${stem}`;
2305
+ const target = this.#mirrorPath(contained);
2306
+ const buildinfo = (0, node_path.join)(this.#mirror, ".probe", `${project.replaceAll("/", "-")}.tsbuildinfo`);
2307
+ const files = [...(config.files ?? []).filter((entry) => (0, node_fs.existsSync)((0, node_path.resolve)((0, node_path.dirname)(target), entry))), ...drafts.map((draft) => relativeWorkspaceFile((0, node_path.dirname)(target), this.#mirrorPath(this.#contain(draft.path))))];
2308
+ const body = {
2309
+ extends: `./${(0, node_path.basename)(project)}`,
2310
+ compilerOptions: {
2311
+ noEmit: true,
2312
+ declaration: false,
2313
+ emitDeclarationOnly: false,
2314
+ composite: false,
2315
+ incremental: true,
2316
+ tsBuildInfoFile: relativeWorkspaceFile((0, node_path.dirname)(target), buildinfo)
2317
+ },
2318
+ ...files.length === 0 ? {} : { files }
2013
2319
  };
2014
- return {
2015
- origin: "claimant",
2016
- path,
2017
- message,
2018
- range: {
2019
- start: diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start),
2020
- end: diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start + (diagnostic.length ?? 0))
2320
+ (0, node_fs.mkdirSync)((0, node_path.dirname)(target), { recursive: true });
2321
+ (0, node_fs.writeFileSync)(target, `${JSON.stringify(body, void 0, " ")}\n`, "utf8");
2322
+ return contained;
2323
+ }
2324
+ #issues(diagnostics, project) {
2325
+ const issues = [];
2326
+ for (const diagnostic of diagnostics) {
2327
+ const message = this.#translate(diagnostic.message);
2328
+ const resolved = diagnostic.path === void 0 ? void 0 : (0, node_path.resolve)(this.#mirror, diagnostic.path);
2329
+ const drafted = resolved !== void 0 && this.#drafts.has(escapesRoot(this.#mirror, resolved) ? relativeWorkspaceFile(this.#workspace, resolved) : relativeWorkspaceFile(this.#mirror, resolved));
2330
+ if (resolved === void 0 || !drafted && (0, node_path.extname)(resolved) === ".json") throw new _src_core.ProbeError(message, {
2331
+ origin: "workspace",
2332
+ code: "malformed",
2333
+ context: {
2334
+ stage: this.stage,
2335
+ project
2336
+ }
2337
+ });
2338
+ const path = escapesRoot(this.#mirror, resolved) ? escapesRoot(this.#workspace, resolved) ? normalizePath(resolved) : relativeWorkspaceFile(this.#workspace, resolved) : relativeWorkspaceFile(this.#mirror, resolved);
2339
+ issues.push({
2340
+ origin: "claimant",
2341
+ path,
2342
+ message,
2343
+ ...diagnostic.range === void 0 ? {} : { range: diagnostic.range }
2344
+ });
2345
+ }
2346
+ return issues;
2347
+ }
2348
+ #translate(message) {
2349
+ return relativeWorkspaceMessage(this.#workspace, relativeWorkspaceMessage(this.#mirror, message));
2350
+ }
2351
+ #fault(execution, project) {
2352
+ const reported = scanDiagnostics(execution.stdout).map((diagnostic) => diagnostic.message);
2353
+ const text = reported.length > 0 ? reported.join("\n") : execution.stdout.trim();
2354
+ return new _src_core.ProbeError(this.#translate(text === "" ? "The compiler printed no configuration" : text), {
2355
+ origin: "workspace",
2356
+ code: "malformed",
2357
+ context: {
2358
+ stage: this.stage,
2359
+ project
2021
2360
  }
2022
- };
2361
+ });
2362
+ }
2363
+ #spawn(args, cwd) {
2364
+ this.#refuseDestroyed();
2365
+ return new Promise((settle, refuse) => {
2366
+ const child = (0, node_child_process.spawn)(process.execPath, [this.#compiler, ...args], {
2367
+ cwd,
2368
+ stdio: [
2369
+ "ignore",
2370
+ "pipe",
2371
+ "pipe"
2372
+ ]
2373
+ });
2374
+ const output = child.stdout;
2375
+ const errors = child.stderr;
2376
+ if (output === null || errors === null) {
2377
+ this.#terminate(child);
2378
+ refuse(new _src_core.ProbeError("The compiler was spawned without its own output streams", {
2379
+ origin: "instrument",
2380
+ code: "malformed",
2381
+ context: { stage: this.stage }
2382
+ }));
2383
+ return;
2384
+ }
2385
+ this.#children.add(child);
2386
+ let stdout = "";
2387
+ let stderr = "";
2388
+ output.setEncoding("utf8");
2389
+ errors.setEncoding("utf8");
2390
+ output.on("data", (chunk) => {
2391
+ stdout += chunk;
2392
+ });
2393
+ errors.on("data", (chunk) => {
2394
+ stderr += chunk;
2395
+ });
2396
+ child.on("error", (error) => {
2397
+ this.#children.delete(child);
2398
+ refuse(new _src_core.ProbeError("The workspace compiler could not be started", {
2399
+ origin: "workspace",
2400
+ code: "malformed",
2401
+ context: {
2402
+ stage: this.stage,
2403
+ path: this.#compiler
2404
+ },
2405
+ cause: error
2406
+ }));
2407
+ });
2408
+ child.on("close", (code) => {
2409
+ this.#children.delete(child);
2410
+ settle({
2411
+ ...code === null ? {} : { status: code },
2412
+ stdout,
2413
+ stderr
2414
+ });
2415
+ });
2416
+ });
2417
+ }
2418
+ #terminate(child) {
2419
+ const id = child.pid;
2420
+ if (process.platform === "win32" && id !== void 0) {
2421
+ (0, node_child_process.spawnSync)("taskkill", [
2422
+ "/pid",
2423
+ String(id),
2424
+ "/t",
2425
+ "/f"
2426
+ ], { stdio: "ignore" });
2427
+ return;
2428
+ }
2429
+ child.kill("SIGTERM");
2023
2430
  }
2024
2431
  };
2025
2432
  //#endregion
2026
2433
  //#region src/server/Probe.ts
2027
2434
  /**
2028
- * Answers claims through resident TypeScript, Oxlint, and Vitest stages.
2435
+ * Answers claims through its type, lint, and runtime stages.
2029
2436
  *
2030
2437
  * @remarks
2031
2438
  * Construction resolves the target workspace's toolchain and begins warming every stage. The boot
@@ -2037,10 +2444,45 @@ var TypeStage = class {
2037
2444
  * claim costs that claim rather than the process. A failed boot is replaced the same way: the next
2038
2445
  * claim runs the controls again rather than inheriting a refusal.
2039
2446
  *
2040
- * @example
2447
+ * @example The claim that earns a receipt
2041
2448
  * ```ts
2042
- * const probe = new Probe({ workspace: '/srv/checkout' })
2449
+ * import type { Claim } from '@orkestrel/probe'
2450
+ * import { Probe } from '@orkestrel/probe/server'
2451
+ *
2452
+ * const claim: Claim = {
2453
+ * project: 'configs/src/tsconfig.core.json',
2454
+ * case: {
2455
+ * files: [
2456
+ * {
2457
+ * path: 'src/core/factories.ts',
2458
+ * text: "export function createGreeting(): string {\n\treturn 'hi'\n}\n",
2459
+ * },
2460
+ * ],
2461
+ * test: {
2462
+ * path: 'tmp/probe/greeting.test.ts',
2463
+ * text: "import { expect, test } from 'vitest'\nimport { createGreeting } from '../../src/core/factories.js'\ntest('greets', () => expect(createGreeting()).toBe('hi'))\n",
2464
+ * },
2465
+ * },
2466
+ * control: {
2467
+ * files: [
2468
+ * {
2469
+ * path: 'src/core/factories.ts',
2470
+ * text: "export function createGreeting(): number {\n\treturn 'hi'\n}\n",
2471
+ * },
2472
+ * ],
2473
+ * test: {
2474
+ * path: 'tmp/probe/greeting.test.ts',
2475
+ * text: "import { expect, test } from 'vitest'\nimport { createGreeting } from '../../src/core/factories.js'\ntest('greets', () => expect(createGreeting()).toBe('hi'))\n",
2476
+ * },
2477
+ * stage: 'type',
2478
+ * reason: 'a string returned as a number must not compile',
2479
+ * },
2480
+ * }
2481
+ *
2482
+ * const probe = new Probe({ workspace: process.cwd() })
2043
2483
  * const verdict = await probe.prove(claim)
2484
+ * verdict.digest // 'fcb88a2dee987b8673c1fc7107979470'
2485
+ * verdict.receipt // 'probe:fcb88a2dee987b8673c1fc7107979470:type:typescript@6.0.3:oxlint@1.82.0:vitest@4.1.11:configs/src/tsconfig.core.json@434f59254d58cf2683d453a26bd0d837'
2044
2486
  * await probe.destroy()
2045
2487
  * ```
2046
2488
  */
@@ -2060,9 +2502,8 @@ var Probe = class {
2060
2502
  #typeTail = Promise.resolve();
2061
2503
  #arming;
2062
2504
  #closing;
2063
- #destroyed = false;
2064
2505
  /**
2065
- * Resolves the target toolchain and starts warming the resident stages.
2506
+ * Resolves the target toolchain and starts warming every stage.
2066
2507
  *
2067
2508
  * @param options - Workspace, deadline, and initial observation hooks
2068
2509
  */
@@ -2110,7 +2551,7 @@ var Probe = class {
2110
2551
  this.#support();
2111
2552
  this.#admit(claim);
2112
2553
  await this.#ready();
2113
- if (this.#destroyed) throw (0, _src_core.createDestroyedError)("probe");
2554
+ if (this.#closing !== void 0) throw (0, _src_core.createDestroyedError)("probe");
2114
2555
  const started = performance.now();
2115
2556
  const id = (0, node_crypto.randomUUID)();
2116
2557
  const project = await this.#resolve(claim);
@@ -2144,7 +2585,6 @@ var Probe = class {
2144
2585
  }
2145
2586
  destroy() {
2146
2587
  if (this.#closing !== void 0) return this.#closing;
2147
- this.#destroyed = true;
2148
2588
  this.#closing = this.#destroy();
2149
2589
  return this.#closing;
2150
2590
  }
@@ -2154,7 +2594,7 @@ var Probe = class {
2154
2594
  await attempt;
2155
2595
  return;
2156
2596
  } catch (error) {
2157
- if (this.#destroyed) throw error;
2597
+ if (this.#closing !== void 0) throw error;
2158
2598
  }
2159
2599
  if (this.#arming === attempt) {
2160
2600
  this.#arming = this.#arm();
@@ -2206,8 +2646,8 @@ var Probe = class {
2206
2646
  }
2207
2647
  async #boot(created) {
2208
2648
  const revision = `${process.pid}-${(0, node_crypto.randomUUID)()}`;
2209
- const typeDependency = createRevisionFile(this.#workspace, "tmp/probe/arm-type.ts", revision);
2210
- const runtimeDependency = createRevisionFile(this.#workspace, "tmp/probe/arm-runtime.ts", revision);
2649
+ const typeDependency = buildRevisionPath(this.#workspace, "tmp/probe/arm-type.ts", revision);
2650
+ const runtimeDependency = buildRevisionPath(this.#workspace, "tmp/probe/arm-runtime.ts", revision);
2211
2651
  const typeModule = (0, node_path.basename)(typeDependency, ".ts");
2212
2652
  const runtimeModule = (0, node_path.basename)(runtimeDependency, ".ts");
2213
2653
  const typeTest = {
@@ -2228,7 +2668,7 @@ var Probe = class {
2228
2668
  files: [],
2229
2669
  test: typeTest,
2230
2670
  stage: "type",
2231
- reason: "the imported type changed after the resident type host cached it"
2671
+ reason: "the imported type changed on disk between the two inspections"
2232
2672
  }
2233
2673
  };
2234
2674
  const runtimeClaim = {
@@ -2348,7 +2788,7 @@ var Probe = class {
2348
2788
  }
2349
2789
  }
2350
2790
  async #recycle(stage) {
2351
- if (this.#destroyed) return false;
2791
+ if (this.#closing !== void 0) return false;
2352
2792
  const timeout = (0, _orkestrel_timeout.createTimeout)({ ms: this.#deadline });
2353
2793
  timeout.start();
2354
2794
  try {
@@ -2356,7 +2796,7 @@ var Probe = class {
2356
2796
  } catch {} finally {
2357
2797
  timeout.clear();
2358
2798
  }
2359
- if (this.#destroyed) return false;
2799
+ if (this.#closing !== void 0) return false;
2360
2800
  if (stage === this.#type) {
2361
2801
  this.#type = new TypeStage(this.#workspace);
2362
2802
  return true;
@@ -2414,13 +2854,17 @@ var Probe = class {
2414
2854
  }
2415
2855
  async #destroy() {
2416
2856
  try {
2417
- await this.#arming;
2418
- } catch {}
2419
- await Promise.all([
2420
- this.#destroyStage(this.#type),
2421
- this.#destroyStage(this.#lint),
2422
- this.#destroyStage(this.#runtime)
2423
- ]);
2857
+ try {
2858
+ await this.#arming;
2859
+ } catch {}
2860
+ await Promise.all([
2861
+ this.#destroyStage(this.#type),
2862
+ this.#destroyStage(this.#lint),
2863
+ this.#destroyStage(this.#runtime)
2864
+ ]);
2865
+ } finally {
2866
+ this.#emitter.destroy();
2867
+ }
2424
2868
  }
2425
2869
  async #destroyStage(stage) {
2426
2870
  try {
@@ -2471,7 +2915,9 @@ var Probe = class {
2471
2915
  //#endregion
2472
2916
  //#region src/server/ProbeServer.ts
2473
2917
  /**
2474
- * Serves one probe over this process's Model Context Protocol stdio transport.
2918
+ * Implements `ProbeServerInterface` over a `PassThrough` stream this server owns, binding the
2919
+ * published `prove` tool and the dual-era dispatcher to this process's Model Context Protocol
2920
+ * stdio transport.
2475
2921
  *
2476
2922
  * @remarks
2477
2923
  * Construction creates the probe, publishes the `prove` tool, and binds the dual-era dispatcher to
@@ -2643,21 +3089,28 @@ exports.Probe = Probe;
2643
3089
  exports.ProbeServer = ProbeServer;
2644
3090
  exports.RuntimeStage = RuntimeStage;
2645
3091
  exports.TypeStage = TypeStage;
3092
+ exports.buildRevisionPath = buildRevisionPath;
2646
3093
  exports.captureListeners = captureListeners;
3094
+ exports.collectWorkspaceFiles = collectWorkspaceFiles;
2647
3095
  exports.computeDigest = computeDigest;
2648
- exports.createRevisionFile = createRevisionFile;
2649
3096
  exports.describeUnknown = describeUnknown;
3097
+ exports.escapesRoot = escapesRoot;
3098
+ exports.filterUniqueIssues = filterUniqueIssues;
2650
3099
  exports.findRefusedPaths = findRefusedPaths;
2651
3100
  exports.guardStage = guardStage;
2652
3101
  exports.inferDocumentLanguage = inferDocumentLanguage;
2653
3102
  exports.inferTestProject = inferTestProject;
2654
3103
  exports.inferTypeProject = inferTypeProject;
2655
3104
  exports.isRefusedName = isRefusedName;
2656
- exports.loadWorkspaceModule = loadWorkspaceModule;
3105
+ exports.loadWorkspaceVitest = loadWorkspaceVitest;
3106
+ exports.matchesLiveProcess = matchesLiveProcess;
2657
3107
  exports.matchesWorkspaceModule = matchesWorkspaceModule;
2658
3108
  exports.normalizePath = normalizePath;
2659
3109
  exports.normalizeValue = normalizeValue;
2660
3110
  exports.overwriteFile = overwriteFile;
3111
+ exports.parseProjectConfig = parseProjectConfig;
3112
+ exports.parseRevisionOwner = parseRevisionOwner;
3113
+ exports.readFaultCode = readFaultCode;
2661
3114
  exports.readWorkspaceManifest = readWorkspaceManifest;
2662
3115
  exports.relativeWorkspaceFile = relativeWorkspaceFile;
2663
3116
  exports.relativeWorkspaceMessage = relativeWorkspaceMessage;
@@ -2665,5 +3118,6 @@ exports.releaseListeners = releaseListeners;
2665
3118
  exports.resolveWorkspaceBinary = resolveWorkspaceBinary;
2666
3119
  exports.resolveWorkspaceFile = resolveWorkspaceFile;
2667
3120
  exports.resolveWorkspaceModule = resolveWorkspaceModule;
3121
+ exports.scanDiagnostics = scanDiagnostics;
2668
3122
 
2669
3123
  //# sourceMappingURL=index.cjs.map