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