@orkestrel/probe 0.0.12 → 0.0.14

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, PROBE_SPECIFICATIONS, ProbeError, RUNTIME_PLUGIN, 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";
@@ -324,6 +324,65 @@ function relativeWorkspaceMessage(workspace, message) {
324
324
  return text;
325
325
  }
326
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
+ /**
327
386
  * Resolves one installed module from the target workspace.
328
387
  *
329
388
  * @param workspace - The target workspace root
@@ -349,18 +408,35 @@ function resolveWorkspaceModule(workspace, specifier) {
349
408
  cause: error
350
409
  });
351
410
  }
352
- 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) {
353
430
  const outcome = attempt(() => {
354
- const require = createRequire(resolve(workspace, "package.json"));
355
- return specifier === "typescript" ? require("typescript") : require("vitest/node");
431
+ return createRequire(resolve(workspace, "package.json"))("vitest/node");
356
432
  });
357
433
  if (outcome.success) return outcome.value;
358
434
  const error = outcome.error;
359
435
  const missing = readFaultCode(error) === "MODULE_NOT_FOUND";
360
- throw new ProbeError(`The workspace cannot load ${specifier}`, {
436
+ throw new ProbeError("The workspace cannot load vitest/node", {
361
437
  origin: "workspace",
362
438
  code: missing ? "missing" : "malformed",
363
- context: { name: specifier },
439
+ context: { name: "vitest/node" },
364
440
  cause: error
365
441
  });
366
442
  }
@@ -433,20 +509,26 @@ function readWorkspaceManifest(workspace, name) {
433
509
  /**
434
510
  * Resolves a package's portable JavaScript binary from the target workspace.
435
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
+ *
436
517
  * @param workspace - The target workspace root
437
- * @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`
438
520
  * @returns The absolute JavaScript entry named by the package's `bin` field
439
- * @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
440
522
  *
441
523
  * @example
442
524
  * ```ts
443
525
  * relativeWorkspaceFile(process.cwd(), resolveWorkspaceBinary(process.cwd(), 'oxlint'))
444
526
  * // 'node_modules/oxlint/bin/oxlint'
445
- * resolveWorkspaceBinary(process.cwd(), 'typescript')
446
- * // throws: typescript does not publish the typescript binary
527
+ * relativeWorkspaceFile(process.cwd(), resolveWorkspaceBinary(process.cwd(), 'typescript', 'tsc'))
528
+ * // 'node_modules/typescript/bin/tsc'
447
529
  * ```
448
530
  */
449
- function resolveWorkspaceBinary(workspace, name) {
531
+ function resolveWorkspaceBinary(workspace, name, command = name) {
450
532
  const manifest = readWorkspaceManifest(workspace, name);
451
533
  const bin = manifest.contents.bin;
452
534
  if (bin === void 0) throw new ProbeError(`${name} does not publish a bin field`, {
@@ -455,13 +537,13 @@ function resolveWorkspaceBinary(workspace, name) {
455
537
  context: { name }
456
538
  });
457
539
  if (typeof bin === "string") return resolve(manifest.path, "..", bin);
458
- 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`, {
459
541
  origin: "workspace",
460
542
  code: "missing",
461
543
  context: { name }
462
544
  });
463
- const entry = bin[name];
464
- 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`, {
465
547
  origin: "workspace",
466
548
  code: "malformed",
467
549
  context: {
@@ -562,6 +644,39 @@ function buildRevisionPath(workspace, path, revision) {
562
644
  return `${extension === "" ? file : file.slice(0, -extension.length)}.probe-${revision}${extension}`;
563
645
  }
564
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
+ /**
565
680
  * Reports whether a path is a workspace module Vitest can cache.
566
681
  *
567
682
  * @param path - The candidate file path
@@ -577,6 +692,75 @@ function matchesWorkspaceModule(path) {
577
692
  return /\.(?:[cm]?[jt]sx?|vue|json)$/.test(path);
578
693
  }
579
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
+ /**
580
764
  * Normalizes a caught or foreign error into readable text.
581
765
  *
582
766
  * @param value - The value to describe
@@ -595,9 +779,9 @@ function describeUnknown(value) {
595
779
  return String(value);
596
780
  }
597
781
  /**
598
- * Guards one resident-stage operation with the stage failure contract.
782
+ * Guards one stage operation with the stage failure contract.
599
783
  *
600
- * @param stage - The resident stage serving the operation
784
+ * @param stage - The stage serving the operation
601
785
  * @param operation - The operation to settle
602
786
  * @returns The operation's fulfilled value
603
787
  * @throws The original `ProbeError`, or an instrument-owned malformed failure retaining the cause
@@ -669,12 +853,12 @@ function findRefusedPaths(value) {
669
853
  * sorts every record's keys.
670
854
  *
671
855
  * @remarks
672
- * A parsed TypeScript project carries the absolute root it was parsed at, so the same commit
673
- * checked out at two paths produces two values that describe one configuration. Rewriting those
674
- * members makes the value portable and stops a host path leaking into anything derived from it. A
675
- * path that escapes the workspace is left as it stands, because rewriting it would name a file
676
- * outside the tree by a relative spelling. Key order is the parser's, not the configuration's, so
677
- * 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.
678
862
  *
679
863
  * @param workspace - The target workspace root
680
864
  * @param value - The value to rewrite
@@ -775,9 +959,66 @@ function releaseListeners(emitter, capture) {
775
959
  }
776
960
  }
777
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
778
1018
  //#region src/server/Overlay.ts
779
1019
  /**
780
- * 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.
781
1022
  *
782
1023
  * @remarks
783
1024
  * One overlay belongs to one inspection: the inspection creates it, records every candidate it
@@ -786,55 +1027,44 @@ function releaseListeners(emitter, capture) {
786
1027
  * is what makes a resident tool re-read a path this overlay holds, so two inspections that supply
787
1028
  * different text for one path never share a cached answer.
788
1029
  *
789
- * This overlay matches a lookup key the way the host its stage declares to its tool matches file
790
- * names. A host that resolves two spellings of one file name to one file leaves the tool holding
791
- * whichever spelling it met first — the disk one, where the file is on disk — and the tool asks
792
- * under that, so an overlay matching keys exactly answers nothing and the tool reads the committed
793
- * file instead. Mint the overlay with the sensitivity the stage declares, and one reading decides
794
- * both. The recorded spelling is what `paths` reports and what `covers` matches a directory
795
- * against, so a folded key never reaches the paths a tool is handed and containment stays an exact
796
- * 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.
797
1036
  *
798
1037
  * @example
799
1038
  * ```ts
800
1039
  * const overlay = new Overlay()
801
- * overlay.set('/srv/checkout/src/core/greeting.ts', "export const GREETING = 'hi'\n")
802
- * 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))
803
1043
  * overlay.clear()
804
1044
  * ```
805
1045
  */
806
1046
  var Overlay = class {
807
1047
  #revision = randomUUID();
808
- #sensitive;
809
1048
  #candidates = /* @__PURE__ */ new Map();
810
- /**
811
- * Creates an empty candidate set under a fresh identity.
812
- *
813
- * @param options - Construction options
814
- */
815
- constructor(options = {}) {
816
- this.#sensitive = options.sensitive ?? true;
817
- }
818
1049
  get revision() {
819
1050
  return this.#revision;
820
1051
  }
821
1052
  get paths() {
822
- return [...this.#candidates.values()].map(([path]) => path);
1053
+ return [...this.#candidates.keys()];
823
1054
  }
824
1055
  /**
825
1056
  * Records one candidate's text against the absolute path it stands in for.
826
1057
  *
827
1058
  * @remarks
828
- * Recording a candidate under a path this overlay's own matching reads as one already recorded
829
- * 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.
830
1061
  *
831
1062
  * @param path - The absolute path the candidate replaces
832
1063
  * @param text - The candidate's full contents
833
1064
  * @returns Nothing
834
1065
  */
835
1066
  set(path, text) {
836
- const recorded = normalizePath(path);
837
- this.#candidates.set(this.#key(recorded), [recorded, text]);
1067
+ this.#candidates.set(normalizePath(path), text);
838
1068
  }
839
1069
  /**
840
1070
  * Reads the candidate text recorded for one absolute path.
@@ -843,7 +1073,7 @@ var Overlay = class {
843
1073
  * @returns The recorded text, or `undefined` when this overlay holds no candidate there
844
1074
  */
845
1075
  text(path) {
846
- return this.#candidates.get(this.#key(normalizePath(path)))?.[1];
1076
+ return this.#candidates.get(normalizePath(path));
847
1077
  }
848
1078
  /**
849
1079
  * Checks whether a candidate sits beneath one directory.
@@ -852,16 +1082,14 @@ var Overlay = class {
852
1082
  * The answer is derived from the paths the overlay holds rather than stored, so it stops being
853
1083
  * true exactly when the inspection that declared those candidates clears them. Both sides pass
854
1084
  * through `normalizePath` first, because a tool that normalizes its own paths asks about a
855
- * directory in a spelling the recorded path may not share. Case is never folded here: this is a
856
- * containment comparison, and it reads the recorded spelling whatever an overlay minted for a
857
- * case-folding host matches its lookup keys by.
1085
+ * directory in a spelling the recorded path may not share.
858
1086
  *
859
1087
  * @param directory - The absolute directory path to check
860
1088
  * @returns True if a candidate path sits beneath the directory; false otherwise
861
1089
  */
862
1090
  covers(directory) {
863
1091
  const base = `${normalizePath(directory).replace(/\/+$/, "")}/`;
864
- 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;
865
1093
  return false;
866
1094
  }
867
1095
  /**
@@ -872,15 +1100,12 @@ var Overlay = class {
872
1100
  clear() {
873
1101
  this.#candidates.clear();
874
1102
  }
875
- #key(path) {
876
- return this.#sensitive ? path : path.toLowerCase();
877
- }
878
1103
  };
879
1104
  //#endregion
880
1105
  //#region package.json
881
- var version = "0.0.12";
1106
+ var version = "0.0.14";
882
1107
  var peerDependencies = {
883
- "oxlint": "^1.80.0",
1108
+ "oxlint": "^1.82.0",
884
1109
  "typescript": "^6.0.3",
885
1110
  "vitest": "^4.1.11"
886
1111
  };
@@ -1161,12 +1386,14 @@ var RuntimeStage = class {
1161
1386
  /**
1162
1387
  * Starts warming the target workspace's Vitest service.
1163
1388
  *
1389
+ * @remarks Loading starts during this call. A loader refusal rejects the owned warm operation
1390
+ * rather than construction, so `inspect` reports it and `destroy` remains available.
1391
+ *
1164
1392
  * @param workspace - The target workspace root. Default: the current working directory
1165
1393
  */
1166
1394
  constructor(workspace = process.cwd()) {
1167
1395
  this.#workspace = workspace;
1168
- const vitest = loadWorkspaceModule(this.#workspace, "vitest/node");
1169
- this.#store(this.#warm(vitest.createVitest));
1396
+ this.#store(this.#warm());
1170
1397
  }
1171
1398
  get stage() {
1172
1399
  return "runtime";
@@ -1174,9 +1401,29 @@ var RuntimeStage = class {
1174
1401
  get progress() {
1175
1402
  return this.#progress;
1176
1403
  }
1404
+ /**
1405
+ * Inspects one case.
1406
+ *
1407
+ * @param subject - The candidate drafts and test to inspect
1408
+ * @returns One outcome for this stage
1409
+ * @throws When the workspace's own tool cannot start or has already been destroyed
1410
+ */
1177
1411
  inspect(subject) {
1178
1412
  return guardStage(this.stage, this.#inspect(subject));
1179
1413
  }
1414
+ /**
1415
+ * Tears down the resident tool or the mirror and releases its resources.
1416
+ *
1417
+ * @remarks
1418
+ * A stage abandons every inspection it holds rather than waiting behind one, so teardown never
1419
+ * waits for an inspection to return. An abandoned inspection rejects, either at the stage's own
1420
+ * guard or as the owned tool closes. Teardown is bounded whatever the stage's own tool does: a
1421
+ * tool that answers neither its warming exchange nor its ending is signalled and released at the
1422
+ * stage's own deadline. A coordinator replaces a stage whose worker no longer returns because
1423
+ * teardown neither waits for an inspection nor waits past that deadline.
1424
+ *
1425
+ * @returns A promise that settles after the resident tool or the mirror releases its resources
1426
+ */
1180
1427
  destroy() {
1181
1428
  if (this.#closing !== void 0) return this.#closing;
1182
1429
  this.#closing = guardStage(this.stage, this.#destroy());
@@ -1277,13 +1524,14 @@ var RuntimeStage = class {
1277
1524
  }
1278
1525
  }
1279
1526
  async #warm(create) {
1527
+ const factory = create ?? loadWorkspaceVitest(this.#workspace).createVitest;
1280
1528
  this.#sweep();
1281
1529
  const output = new PassThrough();
1282
1530
  output.resume();
1283
1531
  const signals = captureListeners(process, ["SIGINT", "SIGTERM"]);
1284
1532
  let warming;
1285
1533
  try {
1286
- warming = create("test", {
1534
+ warming = factory("test", {
1287
1535
  root: this.#workspace,
1288
1536
  config: resolveWorkspaceFile(this.#workspace, "vite.config.ts"),
1289
1537
  watch: false,
@@ -1520,13 +1768,13 @@ var RuntimeStage = class {
1520
1768
  const current = this.#vitest;
1521
1769
  if (current !== void 0 && this.#specifications < PROBE_SPECIFICATIONS) return current;
1522
1770
  if (current !== void 0) return this.#store(this.#replace(current));
1523
- const runner = loadWorkspaceModule(this.#workspace, "vitest/node");
1771
+ const runner = loadWorkspaceVitest(this.#workspace);
1524
1772
  return this.#store(this.#specifications < PROBE_SPECIFICATIONS ? this.#warm(runner.createVitest) : this.#replace(void 0, runner.createVitest));
1525
1773
  }
1526
1774
  async #replace(current, create) {
1527
1775
  if (current !== void 0) await (await current).close();
1528
1776
  if (this.#closing !== void 0) throw createDestroyedError("runtime stage");
1529
- const runner = create ?? loadWorkspaceModule(this.#workspace, "vitest/node").createVitest;
1777
+ const runner = create ?? loadWorkspaceVitest(this.#workspace).createVitest;
1530
1778
  const replacement = await this.#warm(runner);
1531
1779
  this.#specifications = 0;
1532
1780
  return replacement;
@@ -1570,7 +1818,7 @@ var RuntimeStage = class {
1570
1818
  }
1571
1819
  #snapshot() {
1572
1820
  const modules = /* @__PURE__ */ new Map();
1573
- for (const path of this.#walk()) {
1821
+ for (const path of collectWorkspaceFiles(this.#workspace)) {
1574
1822
  if (!matchesWorkspaceModule(path)) continue;
1575
1823
  try {
1576
1824
  const digest = createHash("sha256").update(readFileSync(path)).digest("hex");
@@ -1581,12 +1829,12 @@ var RuntimeStage = class {
1581
1829
  return modules;
1582
1830
  }
1583
1831
  #sweep() {
1584
- for (const path of this.#walk()) {
1832
+ for (const path of collectWorkspaceFiles(this.#workspace)) {
1585
1833
  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);
1586
1834
  const revision = match?.[1];
1587
1835
  const owner = match?.[2];
1588
1836
  if (revision === void 0 || owner === void 0) continue;
1589
- if (this.#alive(Number.parseInt(owner, 10))) continue;
1837
+ if (matchesLiveProcess(Number.parseInt(owner, 10))) continue;
1590
1838
  if (!this.#owned(path, revision)) continue;
1591
1839
  try {
1592
1840
  unlinkSync(resolveWorkspaceFile(this.#workspace, relative(this.#workspace, path), true));
@@ -1597,35 +1845,6 @@ var RuntimeStage = class {
1597
1845
  const outcome = attempt(() => readFileSync(path, "utf8"));
1598
1846
  return outcome.success && matchesSpecification(outcome.value, revision);
1599
1847
  }
1600
- #alive(id) {
1601
- if (!Number.isSafeInteger(id) || id <= 0) return true;
1602
- try {
1603
- process.kill(id, 0);
1604
- return true;
1605
- } catch (error) {
1606
- return readFaultCode(error) === "EPERM";
1607
- }
1608
- }
1609
- *#walk() {
1610
- const directories = [resolve(this.#workspace)];
1611
- while (directories.length > 0) {
1612
- const directory = directories.pop();
1613
- if (directory === void 0) break;
1614
- let entries = [];
1615
- try {
1616
- entries = readdirSync(directory, { withFileTypes: true });
1617
- } catch {}
1618
- for (const entry of entries) {
1619
- if (entry.name === ".git" || entry.name === "dist" || entry.name === "node_modules") continue;
1620
- const path = join(directory, entry.name);
1621
- if (entry.isDirectory()) {
1622
- directories.push(path);
1623
- continue;
1624
- }
1625
- if (entry.isFile()) yield path;
1626
- }
1627
- }
1628
- }
1629
1848
  #issues(result, file, original) {
1630
1849
  const specification = this.#real(file);
1631
1850
  const issues = [];
@@ -1741,21 +1960,32 @@ var RuntimeStage = class {
1741
1960
  //#endregion
1742
1961
  //#region src/server/stages/TypeStage.ts
1743
1962
  /**
1744
- * Inspects TypeScript source through resident language services from the target workspace.
1963
+ * Inspects TypeScript source by running the target workspace's own compiler over a mirror of it.
1745
1964
  *
1746
1965
  * @remarks
1747
- * Construction starts loading the workspace's compiler and warming one service per project the
1748
- * workspace declares. A candidate draft is checked against the project a call names, or
1749
- * against its own scoped environment project when a call names none, while the test uses the root
1750
- * project. Disk snapshots use their modification time as the service version so dependency edits
1751
- * cannot leave a warm answer stale. Candidate text lives in an overlay the inspection owns, and
1752
- * the language-service host answers existence, reads, and versions from it, so a candidate that
1753
- * exists only as text is importable and a candidate that shadows a disk file is checked as the
1754
- * text the case supplied. A project outside the declared set holds one recycled slot, so a caller
1755
- * varying the project cannot grow the resident set. A language service checks one candidate
1756
- * synchronously, so the stage hands the host's event loop back at each candidate boundary: a
1757
- * caller's deadline is answered within one candidate's check rather than after the whole
1758
- * inspection, and an inspection abandoned at that deadline stops at the next boundary.
1966
+ * The compiler is a process this stage spawns rather than a module it loads, so nothing here holds
1967
+ * a resident program and the host's event loop is free for the whole check. Construction sweeps a
1968
+ * mirror an earlier host left behind, copies the workspace into a fresh one under
1969
+ * {@link TYPE_MIRROR}, and builds each declared project's incremental state there, so the first
1970
+ * inspection is warm.
1971
+ *
1972
+ * Every inspection refreshes the mirror by content digest, writes each candidate draft and the test
1973
+ * at its mirrored declared path, and runs `tsc --noEmit --pretty false` once per distinct selected
1974
+ * project. A draft therefore shadows the file it replaces: a consumer of that path is checked
1975
+ * against the draft's text, and a draft importing a sibling draft resolves to the sibling draft.
1976
+ * Nothing is written outside `tmp/`, and the workspace's own copy of a drafted file never moves.
1977
+ *
1978
+ * Each run reads a scratch project this stage writes beside the mirrored project it extends, so
1979
+ * every relative path the project declares — its own `rootDir`, `include`, `paths`, and the
1980
+ * projects it extends — resolves inside the mirror without being rewritten. The scratch adds the
1981
+ * drafts as `files`, which the project's own `exclude` does not reach, and its incremental state
1982
+ * lives in the mirror so reuse survives the refresh.
1983
+ *
1984
+ * The diagnostics decide the outcome, never the exit code, which the supported compiler majors
1985
+ * disagree on. A diagnostic naming a project file, and one naming no file at all, is the target
1986
+ * tree's own configuration fault and raises rather than reporting, unless the `.json` file is one
1987
+ * the claim itself drafted; every other diagnostic is a claimant issue at the point the compiler
1988
+ * reported, which is the extent the plain-text output carries.
1759
1989
  *
1760
1990
  * @example
1761
1991
  * ```ts
@@ -1766,26 +1996,27 @@ var RuntimeStage = class {
1766
1996
  */
1767
1997
  var TypeStage = class {
1768
1998
  #workspace;
1769
- #typescript;
1770
- #services = /* @__PURE__ */ new Map();
1771
- #resident = /* @__PURE__ */ new Set();
1772
- #options = /* @__PURE__ */ new Map();
1773
- #files = /* @__PURE__ */ new Map();
1774
- #diagnostics = /* @__PURE__ */ new Map();
1775
- #overlay = new Overlay();
1776
- #recycled;
1999
+ #compiler;
2000
+ #revision = `${process.pid}-${randomUUID()}`;
2001
+ #mirror;
2002
+ #configs = /* @__PURE__ */ new Map();
2003
+ #mirrored = /* @__PURE__ */ new Map();
2004
+ #drafts = /* @__PURE__ */ new Set();
2005
+ #children = /* @__PURE__ */ new Set();
2006
+ #warming;
1777
2007
  #closing;
1778
2008
  #progress = 0;
1779
2009
  /**
1780
- * Starts warming the target workspace's TypeScript service.
2010
+ * Resolves the target workspace's compiler and starts warming its mirror.
1781
2011
  *
1782
2012
  * @param workspace - The target workspace root. Default: the current working directory
1783
2013
  */
1784
2014
  constructor(workspace = process.cwd()) {
1785
2015
  this.#workspace = workspace;
1786
- const typescript = loadWorkspaceModule(this.#workspace, "typescript");
1787
- this.#typescript = this.#warm(typescript);
1788
- this.#typescript.catch(() => {});
2016
+ this.#compiler = resolveWorkspaceBinary(workspace, "typescript", "tsc");
2017
+ this.#mirror = resolveWorkspaceFile(workspace, `${TYPE_MIRROR}/${this.#revision}`);
2018
+ this.#warming = this.#warm();
2019
+ this.#warming.catch(() => {});
1789
2020
  }
1790
2021
  get stage() {
1791
2022
  return "type";
@@ -1805,7 +2036,8 @@ var TypeStage = class {
1805
2036
  * @param project - The workspace-relative TypeScript project the candidate drafts are checked
1806
2037
  * against. Default: the scoped project each candidate path infers
1807
2038
  * @returns One outcome for this stage
1808
- * @throws When the resident compiler cannot start or the stage has already been destroyed
2039
+ * @throws When the workspace refuses the mirror, when a project the run reads is malformed, or
2040
+ * when the stage has already been destroyed
1809
2041
  */
1810
2042
  inspect(subject, project) {
1811
2043
  return guardStage(this.stage, this.#inspect(subject, project));
@@ -1814,15 +2046,15 @@ var TypeStage = class {
1814
2046
  * Resolves one project to the path and digest this stage applies for it.
1815
2047
  *
1816
2048
  * @remarks
1817
- * Reads the parse this stage itself applies, filling its cache when the project is not already
1818
- * resident, so the reported digest is the configuration the inspection is judged under rather
1819
- * than a second parse a caller ran. The returned record is a value copy, so a later eviction
1820
- * does not move it.
2049
+ * Reads the configuration the compiler itself prints for the project as the mirror holds it,
2050
+ * refreshed from the workspace first on a cache miss, so the digest names the configuration the
2051
+ * check applies. The reading is cached per project for the life of the stage, so a draft written
2052
+ * afterward cannot move it.
1821
2053
  *
1822
2054
  * @param project - The workspace-relative TypeScript project to resolve
1823
2055
  * @returns The resolved workspace-relative path and the digest of its compiler options
1824
- * @throws When the project escapes the workspace, cannot be parsed, or the stage has already
1825
- * been destroyed
2056
+ * @throws When the project escapes the workspace, when the compiler refuses it, or when the
2057
+ * stage has already been destroyed
1826
2058
  */
1827
2059
  resolve(project) {
1828
2060
  return guardStage(this.stage, this.#resolve(project));
@@ -1833,81 +2065,76 @@ var TypeStage = class {
1833
2065
  return this.#closing;
1834
2066
  }
1835
2067
  async #inspect(subject, project) {
1836
- if (this.#closing !== void 0) throw createDestroyedError("type stage");
2068
+ this.#refuseDestroyed();
1837
2069
  const started = performance.now();
1838
- const typescript = await this.#typescript;
1839
- if (this.#closing !== void 0) throw createDestroyedError("type stage");
1840
- const selections = subject.files.map((draft) => ({
2070
+ await this.#warming;
2071
+ this.#refuseDestroyed();
2072
+ const test = resolveWorkspaceFile(this.#workspace, subject.test.path);
2073
+ const resolved = subject.files.map((draft) => ({
1841
2074
  draft,
1842
2075
  path: resolveWorkspaceFile(this.#workspace, draft.path)
1843
- })).map((candidate) => ({
1844
- draft: candidate.draft,
1845
- project: project ?? inferTypeProject(relativeWorkspaceFile(this.#workspace, candidate.path))
1846
2076
  }));
1847
- const root = this.#service(typescript, "tsconfig.json");
1848
- this.#configure(root, "tsconfig.json");
1849
- await this.#unblock();
1850
- for (const selection of selections) {
1851
- this.#configure(this.#service(typescript, selection.project), selection.project);
1852
- await this.#unblock();
2077
+ const groups = /* @__PURE__ */ new Map([[this.#contain("tsconfig.json"), [subject.test]]]);
2078
+ for (const candidate of resolved) {
2079
+ const selected = this.#contain(project ?? inferTypeProject(relativeWorkspaceFile(this.#workspace, candidate.path)));
2080
+ const drafts = groups.get(selected);
2081
+ if (drafts === void 0) groups.set(selected, [candidate.draft]);
2082
+ else drafts.push(candidate.draft);
1853
2083
  }
1854
- const overlay = new Overlay({ sensitive: this.#caseSensitive(typescript) });
1855
- this.#overlay = overlay;
2084
+ this.#progress += 1;
2085
+ for (const selected of groups.keys()) await this.#configure(selected);
2086
+ this.#refuseDestroyed();
1856
2087
  try {
1857
- this.#record(subject.test, overlay);
1858
- for (const draft of subject.files) this.#record(draft, overlay);
1859
- this.#progress += 1;
2088
+ this.#refresh();
2089
+ this.#drafts.add(this.#place(subject.test.text, test));
2090
+ for (const candidate of resolved) this.#drafts.add(this.#place(candidate.draft.text, candidate.path));
1860
2091
  const issues = [];
1861
- const projects = /* @__PURE__ */ new Set();
1862
- issues.push(...this.#issues(typescript, root, subject.test, "tsconfig.json", false, true));
1863
- projects.add("tsconfig.json");
1864
- await this.#unblock();
1865
- for (const selection of selections) {
1866
- const draft = selection.draft;
1867
- const selected = selection.project;
1868
- const service = this.#service(typescript, selected);
1869
- issues.push(...this.#issues(typescript, service, draft, selected, project !== void 0, !projects.has(selected)));
1870
- projects.add(selected);
1871
- await this.#unblock();
2092
+ for (const [selected, drafts] of groups) {
2093
+ this.#refuseDestroyed();
2094
+ issues.push(...await this.#check(selected, drafts));
1872
2095
  }
1873
2096
  return {
1874
2097
  stage: this.stage,
1875
2098
  elapsed: Math.round(performance.now() - started),
1876
- issues
2099
+ issues: filterUniqueIssues(issues)
1877
2100
  };
1878
2101
  } finally {
1879
- overlay.clear();
2102
+ this.#release();
1880
2103
  }
1881
2104
  }
1882
2105
  async #resolve(project) {
1883
- if (this.#closing !== void 0) throw createDestroyedError("type stage");
1884
- const typescript = await this.#typescript;
1885
- if (this.#closing !== void 0) throw createDestroyedError("type stage");
1886
- const resolved = resolveWorkspaceFile(this.#workspace, project);
2106
+ this.#refuseDestroyed();
2107
+ const contained = this.#contain(project);
1887
2108
  this.#progress += 1;
1888
- this.#service(typescript, project);
1889
- await this.#unblock();
2109
+ const config = await this.#configure(contained);
1890
2110
  return {
1891
- path: relativeWorkspaceFile(this.#workspace, resolved),
1892
- digest: computeDigest(this.#workspace, this.#options.get(resolved) ?? {})
2111
+ path: contained,
2112
+ digest: computeDigest(this.#workspace, config.compilerOptions)
1893
2113
  };
1894
2114
  }
1895
2115
  async #destroy() {
1896
- await this.#typescript.catch(() => void 0);
1897
- for (const service of this.#services.values()) service.dispose();
1898
- this.#services.clear();
1899
- this.#resident.clear();
1900
- this.#recycled = void 0;
1901
- this.#options.clear();
1902
- this.#files.clear();
1903
- this.#diagnostics.clear();
1904
- }
1905
- async #warm(typescript) {
1906
- for (const project of this.#projects()) {
1907
- this.#resident.add(resolveWorkspaceFile(this.#workspace, project));
1908
- this.#service(typescript, project);
1909
- }
1910
- return typescript;
2116
+ const children = [...this.#children];
2117
+ this.#children.clear();
2118
+ for (const child of children) this.#terminate(child);
2119
+ await this.#warming.catch(() => void 0);
2120
+ try {
2121
+ rmSync(this.#mirror, {
2122
+ recursive: true,
2123
+ force: true,
2124
+ maxRetries: 5,
2125
+ retryDelay: 20
2126
+ });
2127
+ } catch {}
2128
+ this.#configs.clear();
2129
+ this.#mirrored.clear();
2130
+ this.#drafts.clear();
2131
+ }
2132
+ async #warm() {
2133
+ this.#sweep();
2134
+ this.#createMirror();
2135
+ this.#refresh();
2136
+ this.#refuseDestroyed();
2137
+ await Promise.all(this.#projects().map((project) => this.#check(project, [])));
1911
2138
  }
1912
2139
  #projects() {
1913
2140
  const projects = ["tsconfig.json"];
@@ -1923,162 +2150,291 @@ var TypeStage = class {
1923
2150
  }
1924
2151
  return projects;
1925
2152
  }
1926
- async #unblock() {
1927
- await setTimeout(0);
2153
+ #refuseDestroyed() {
1928
2154
  if (this.#closing !== void 0) throw createDestroyedError("type stage");
1929
2155
  }
1930
- #record(draft, overlay) {
1931
- overlay.set(resolveWorkspaceFile(this.#workspace, draft.path), draft.text);
1932
- }
1933
- #service(typescript, project) {
1934
- const path = resolveWorkspaceFile(this.#workspace, project);
1935
- const existing = this.#services.get(path);
1936
- if (existing !== void 0) return existing;
1937
- const spelling = normalizePath(path);
1938
- const config = typescript.readConfigFile(spelling, typescript.sys.readFile);
1939
- if (config.error !== void 0) throw new ProbeError(this.#translate(typescript, config.error.messageText), {
1940
- origin: "workspace",
1941
- code: "malformed",
1942
- context: {
1943
- stage: this.stage,
1944
- project
1945
- }
2156
+ #createMirror() {
2157
+ const marker = resolveWorkspaceFile(this.#workspace, `${TYPE_MIRROR}/${this.#revision}/.probe/mirror.txt`, true);
2158
+ mkdirSync(dirname(marker), { recursive: true });
2159
+ writeFileSync(marker, formatSpecification("", this.#revision), {
2160
+ encoding: "utf8",
2161
+ flag: "wx"
1946
2162
  });
1947
- const parsed = typescript.parseJsonConfigFileContent(config.config, typescript.sys, dirname(spelling), void 0, spelling);
1948
- if (parsed.errors.length > 0) throw new ProbeError(this.#translate(typescript, parsed.errors[0]?.messageText), {
1949
- origin: "workspace",
1950
- code: "malformed",
1951
- context: {
1952
- stage: this.stage,
1953
- project
1954
- }
1955
- });
1956
- this.#options.set(path, parsed.options);
1957
- this.#files.set(path, parsed.fileNames);
1958
- const host = {
1959
- getCompilationSettings: this.#compilationSettings.bind(this, path),
1960
- getScriptFileNames: this.#scriptFiles.bind(this, path),
1961
- getScriptVersion: this.#version.bind(this),
1962
- getScriptSnapshot: this.#snapshot.bind(this, typescript),
1963
- getCurrentDirectory: this.#directory.bind(this),
1964
- getDefaultLibFileName: this.#defaultLibrary.bind(this, typescript),
1965
- fileExists: this.#fileExists.bind(this, typescript),
1966
- readFile: this.#readFile.bind(this, typescript),
1967
- readDirectory: typescript.sys.readDirectory,
1968
- directoryExists: this.#directoryExists.bind(this, typescript),
1969
- getDirectories: typescript.sys.getDirectories,
1970
- useCaseSensitiveFileNames: this.#caseSensitive.bind(this, typescript),
1971
- getNewLine: this.#newline.bind(this, typescript)
1972
- };
1973
- const service = typescript.createLanguageService(host);
1974
- this.#services.set(path, service);
1975
- if (!this.#resident.has(path)) this.#recycle(path);
1976
- return service;
1977
- }
1978
- #compilationSettings(path) {
1979
- return this.#options.get(path) ?? {};
1980
- }
1981
- #scriptFiles(path) {
1982
- return [...this.#files.get(path) ?? [], ...this.#overlay.paths];
1983
2163
  }
1984
- #directory() {
1985
- return this.#workspace;
1986
- }
1987
- #defaultLibrary(typescript, options) {
1988
- return typescript.getDefaultLibFilePath(options);
1989
- }
1990
- #fileExists(typescript, file) {
1991
- return this.#overlay.text(file) !== void 0 || typescript.sys.fileExists(file);
1992
- }
1993
- #readFile(typescript, file) {
1994
- return this.#overlay.text(file) ?? typescript.sys.readFile(file);
1995
- }
1996
- #directoryExists(typescript, directory) {
1997
- return typescript.sys.directoryExists(directory) || this.#overlay.covers(directory);
1998
- }
1999
- #caseSensitive(typescript) {
2000
- return typescript.sys.useCaseSensitiveFileNames;
2164
+ #sweep() {
2165
+ const root = resolveWorkspaceFile(this.#workspace, TYPE_MIRROR);
2166
+ let entries = [];
2167
+ try {
2168
+ entries = readdirSync(root, { withFileTypes: true });
2169
+ } catch {
2170
+ return;
2171
+ }
2172
+ for (const entry of entries) {
2173
+ if (!entry.isDirectory()) continue;
2174
+ const owner = parseRevisionOwner(entry.name);
2175
+ if (owner === void 0 || matchesLiveProcess(owner)) continue;
2176
+ const marker = attempt(() => readFileSync(join(root, entry.name, ".probe", "mirror.txt"), "utf8"));
2177
+ if (!marker.success || !matchesSpecification(marker.value, entry.name)) continue;
2178
+ try {
2179
+ rmSync(join(root, entry.name), {
2180
+ recursive: true,
2181
+ force: true,
2182
+ maxRetries: 5,
2183
+ retryDelay: 20
2184
+ });
2185
+ } catch {}
2186
+ }
2001
2187
  }
2002
- #newline(typescript) {
2003
- return typescript.sys.newLine;
2188
+ #refresh() {
2189
+ const present = /* @__PURE__ */ new Set();
2190
+ for (const path of collectWorkspaceFiles(this.#workspace)) {
2191
+ const contained = relativeWorkspaceFile(this.#workspace, path);
2192
+ present.add(contained);
2193
+ const reading = attempt(() => readFileSync(path));
2194
+ if (!reading.success) continue;
2195
+ const digest = createHash("sha256").update(reading.value).digest("hex");
2196
+ if (this.#mirrored.get(contained) === digest) continue;
2197
+ this.#place(reading.value, path);
2198
+ this.#mirrored.set(contained, digest);
2199
+ }
2200
+ for (const contained of [...this.#mirrored.keys()]) {
2201
+ if (present.has(contained)) continue;
2202
+ this.#remove(contained);
2203
+ this.#mirrored.delete(contained);
2204
+ }
2004
2205
  }
2005
- #translate(typescript, message) {
2006
- return relativeWorkspaceMessage(this.#workspace, typescript.flattenDiagnosticMessageText(message, "\n"));
2206
+ #release() {
2207
+ for (const contained of this.#drafts) {
2208
+ this.#remove(contained);
2209
+ this.#mirrored.delete(contained);
2210
+ }
2211
+ this.#drafts.clear();
2212
+ }
2213
+ #place(text, path) {
2214
+ const contained = relativeWorkspaceFile(this.#workspace, path);
2215
+ const target = this.#mirrorPath(contained);
2216
+ if (attempt(() => {
2217
+ mkdirSync(dirname(target), { recursive: true });
2218
+ writeFileSync(target, text);
2219
+ }).success) return contained;
2220
+ this.#displace(target);
2221
+ mkdirSync(dirname(target), { recursive: true });
2222
+ writeFileSync(target, text);
2223
+ return contained;
2224
+ }
2225
+ #displace(target) {
2226
+ const ancestors = [];
2227
+ let directory = dirname(target);
2228
+ while (directory !== this.#mirror && directory !== dirname(directory)) {
2229
+ ancestors.unshift(directory);
2230
+ directory = dirname(directory);
2231
+ }
2232
+ for (const ancestor of [...ancestors, target]) {
2233
+ const reading = attempt(() => lstatSync(ancestor));
2234
+ if (!reading.success) continue;
2235
+ if (ancestor !== target && reading.value.isDirectory()) continue;
2236
+ if (ancestor === target && !reading.value.isDirectory()) continue;
2237
+ rmSync(ancestor, {
2238
+ recursive: true,
2239
+ force: true,
2240
+ maxRetries: 5,
2241
+ retryDelay: 20
2242
+ });
2243
+ this.#mirrored.delete(relativeWorkspaceFile(this.#mirror, ancestor));
2244
+ }
2007
2245
  }
2008
- #configure(service, project) {
2009
- const path = resolveWorkspaceFile(this.#workspace, project);
2010
- if (this.#diagnostics.has(path)) return;
2011
- this.#diagnostics.set(path, service.getCompilerOptionsDiagnostics());
2246
+ #mirrorPath(contained) {
2247
+ return join(this.#mirror, contained);
2012
2248
  }
2013
- #recycle(path) {
2014
- const previous = this.#recycled;
2015
- this.#recycled = path;
2016
- if (previous === void 0 || previous === path) return;
2017
- this.#services.get(previous)?.dispose();
2018
- this.#services.delete(previous);
2019
- this.#options.delete(previous);
2020
- this.#files.delete(previous);
2021
- this.#diagnostics.delete(previous);
2249
+ #contain(project) {
2250
+ return relativeWorkspaceFile(this.#workspace, resolveWorkspaceFile(this.#workspace, project));
2022
2251
  }
2023
- #version(file) {
2024
- if (this.#overlay.text(file) !== void 0) return `virtual:${this.#overlay.revision}`;
2252
+ #remove(contained) {
2025
2253
  try {
2026
- return `disk:${statSync(file).mtimeMs}`;
2027
- } catch {
2028
- return "missing";
2029
- }
2254
+ rmSync(this.#mirrorPath(contained), {
2255
+ force: true,
2256
+ maxRetries: 5,
2257
+ retryDelay: 20
2258
+ });
2259
+ } catch {}
2030
2260
  }
2031
- #snapshot(typescript, file) {
2032
- const text = this.#overlay.text(file) ?? typescript.sys.readFile(file);
2033
- return text === void 0 ? void 0 : typescript.ScriptSnapshot.fromString(text);
2034
- }
2035
- #issues(typescript, service, draft, project, selected, configure) {
2036
- const path = resolveWorkspaceFile(this.#workspace, draft.path);
2037
- const configuration = resolveWorkspaceFile(this.#workspace, project);
2038
- return [
2039
- ...configure ? this.#diagnostics.get(configuration) ?? [] : [],
2040
- ...service.getSyntacticDiagnostics(path),
2041
- ...service.getSemanticDiagnostics(path)
2042
- ].map((diagnostic) => this.#issue(typescript, diagnostic, project, selected));
2043
- }
2044
- #issue(typescript, diagnostic, project, selected) {
2045
- const message = this.#translate(typescript, diagnostic.messageText);
2046
- if (diagnostic.file === void 0) {
2047
- if (selected) throw new ProbeError(message, {
2048
- origin: "claimant",
2049
- code: "refused",
2261
+ async #configure(project) {
2262
+ const contained = this.#contain(project);
2263
+ const existing = this.#configs.get(contained);
2264
+ if (existing !== void 0) return existing;
2265
+ this.#refresh();
2266
+ const execution = await this.#spawn([
2267
+ "--showConfig",
2268
+ "-p",
2269
+ contained
2270
+ ], this.#mirror);
2271
+ this.#refuseDestroyed();
2272
+ const config = parseProjectConfig(execution.stdout);
2273
+ if (config === void 0) throw this.#fault(execution, contained);
2274
+ this.#configs.set(contained, config);
2275
+ return config;
2276
+ }
2277
+ async #check(project, drafts) {
2278
+ const config = await this.#configure(project);
2279
+ const scratch = this.#scratch(this.#contain(project), config, drafts);
2280
+ const execution = await this.#spawn([
2281
+ "--noEmit",
2282
+ "--pretty",
2283
+ "false",
2284
+ "-p",
2285
+ `./${scratch}`
2286
+ ], this.#mirror);
2287
+ this.#refuseDestroyed();
2288
+ const diagnostics = scanDiagnostics(execution.stdout);
2289
+ if (diagnostics.length === 0 && execution.status !== 0) {
2290
+ const stderr = execution.stderr.trim();
2291
+ 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}`;
2292
+ throw new ProbeError(this.#translate(message), {
2293
+ origin: "instrument",
2294
+ code: "malformed",
2050
2295
  context: {
2051
2296
  stage: this.stage,
2052
2297
  project
2053
2298
  }
2054
2299
  });
2055
- return {
2056
- origin: "workspace",
2057
- path: project,
2058
- message
2059
- };
2060
2300
  }
2061
- const path = relativeWorkspaceFile(this.#workspace, diagnostic.file.fileName);
2062
- if (diagnostic.start === void 0) return {
2063
- origin: "claimant",
2064
- path,
2065
- message
2301
+ return this.#issues(diagnostics, project);
2302
+ }
2303
+ #scratch(project, config, drafts) {
2304
+ const directory = dirname(project);
2305
+ const stem = `${basename(project, extname(project))}.probe.json`;
2306
+ const contained = directory === "." ? stem : `${directory}/${stem}`;
2307
+ const target = this.#mirrorPath(contained);
2308
+ const buildinfo = join(this.#mirror, ".probe", `${project.replaceAll("/", "-")}.tsbuildinfo`);
2309
+ const files = [...(config.files ?? []).filter((entry) => existsSync(resolve(dirname(target), entry))), ...drafts.map((draft) => relativeWorkspaceFile(dirname(target), this.#mirrorPath(this.#contain(draft.path))))];
2310
+ const body = {
2311
+ extends: `./${basename(project)}`,
2312
+ compilerOptions: {
2313
+ noEmit: true,
2314
+ declaration: false,
2315
+ emitDeclarationOnly: false,
2316
+ composite: false,
2317
+ incremental: true,
2318
+ tsBuildInfoFile: relativeWorkspaceFile(dirname(target), buildinfo)
2319
+ },
2320
+ ...files.length === 0 ? {} : { files }
2066
2321
  };
2067
- return {
2068
- origin: "claimant",
2069
- path,
2070
- message,
2071
- range: {
2072
- start: diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start),
2073
- end: diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start + (diagnostic.length ?? 0))
2322
+ mkdirSync(dirname(target), { recursive: true });
2323
+ writeFileSync(target, `${JSON.stringify(body, void 0, " ")}\n`, "utf8");
2324
+ return contained;
2325
+ }
2326
+ #issues(diagnostics, project) {
2327
+ const issues = [];
2328
+ for (const diagnostic of diagnostics) {
2329
+ const message = this.#translate(diagnostic.message);
2330
+ const resolved = diagnostic.path === void 0 ? void 0 : resolve(this.#mirror, diagnostic.path);
2331
+ const drafted = resolved !== void 0 && this.#drafts.has(escapesRoot(this.#mirror, resolved) ? relativeWorkspaceFile(this.#workspace, resolved) : relativeWorkspaceFile(this.#mirror, resolved));
2332
+ if (resolved === void 0 || !drafted && extname(resolved) === ".json") throw new ProbeError(message, {
2333
+ origin: "workspace",
2334
+ code: "malformed",
2335
+ context: {
2336
+ stage: this.stage,
2337
+ project
2338
+ }
2339
+ });
2340
+ const path = escapesRoot(this.#mirror, resolved) ? escapesRoot(this.#workspace, resolved) ? normalizePath(resolved) : relativeWorkspaceFile(this.#workspace, resolved) : relativeWorkspaceFile(this.#mirror, resolved);
2341
+ issues.push({
2342
+ origin: "claimant",
2343
+ path,
2344
+ message,
2345
+ ...diagnostic.range === void 0 ? {} : { range: diagnostic.range }
2346
+ });
2347
+ }
2348
+ return issues;
2349
+ }
2350
+ #translate(message) {
2351
+ return relativeWorkspaceMessage(this.#workspace, relativeWorkspaceMessage(this.#mirror, message));
2352
+ }
2353
+ #fault(execution, project) {
2354
+ const reported = scanDiagnostics(execution.stdout).map((diagnostic) => diagnostic.message);
2355
+ const text = reported.length > 0 ? reported.join("\n") : execution.stdout.trim();
2356
+ return new ProbeError(this.#translate(text === "" ? "The compiler printed no configuration" : text), {
2357
+ origin: "workspace",
2358
+ code: "malformed",
2359
+ context: {
2360
+ stage: this.stage,
2361
+ project
2074
2362
  }
2075
- };
2363
+ });
2364
+ }
2365
+ #spawn(args, cwd) {
2366
+ this.#refuseDestroyed();
2367
+ return new Promise((settle, refuse) => {
2368
+ const child = spawn(process.execPath, [this.#compiler, ...args], {
2369
+ cwd,
2370
+ stdio: [
2371
+ "ignore",
2372
+ "pipe",
2373
+ "pipe"
2374
+ ]
2375
+ });
2376
+ const output = child.stdout;
2377
+ const errors = child.stderr;
2378
+ if (output === null || errors === null) {
2379
+ this.#terminate(child);
2380
+ refuse(new ProbeError("The compiler was spawned without its own output streams", {
2381
+ origin: "instrument",
2382
+ code: "malformed",
2383
+ context: { stage: this.stage }
2384
+ }));
2385
+ return;
2386
+ }
2387
+ this.#children.add(child);
2388
+ let stdout = "";
2389
+ let stderr = "";
2390
+ output.setEncoding("utf8");
2391
+ errors.setEncoding("utf8");
2392
+ output.on("data", (chunk) => {
2393
+ stdout += chunk;
2394
+ });
2395
+ errors.on("data", (chunk) => {
2396
+ stderr += chunk;
2397
+ });
2398
+ child.on("error", (error) => {
2399
+ this.#children.delete(child);
2400
+ refuse(new ProbeError("The workspace compiler could not be started", {
2401
+ origin: "workspace",
2402
+ code: "malformed",
2403
+ context: {
2404
+ stage: this.stage,
2405
+ path: this.#compiler
2406
+ },
2407
+ cause: error
2408
+ }));
2409
+ });
2410
+ child.on("close", (code) => {
2411
+ this.#children.delete(child);
2412
+ settle({
2413
+ ...code === null ? {} : { status: code },
2414
+ stdout,
2415
+ stderr
2416
+ });
2417
+ });
2418
+ });
2419
+ }
2420
+ #terminate(child) {
2421
+ const id = child.pid;
2422
+ if (process.platform === "win32" && id !== void 0) {
2423
+ spawnSync("taskkill", [
2424
+ "/pid",
2425
+ String(id),
2426
+ "/t",
2427
+ "/f"
2428
+ ], { stdio: "ignore" });
2429
+ return;
2430
+ }
2431
+ child.kill("SIGTERM");
2076
2432
  }
2077
2433
  };
2078
2434
  //#endregion
2079
2435
  //#region src/server/Probe.ts
2080
2436
  /**
2081
- * Answers claims through resident TypeScript, Oxlint, and Vitest stages.
2437
+ * Answers claims through its type, lint, and runtime stages.
2082
2438
  *
2083
2439
  * @remarks
2084
2440
  * Construction resolves the target workspace's toolchain and begins warming every stage. The boot
@@ -2090,10 +2446,45 @@ var TypeStage = class {
2090
2446
  * claim costs that claim rather than the process. A failed boot is replaced the same way: the next
2091
2447
  * claim runs the controls again rather than inheriting a refusal.
2092
2448
  *
2093
- * @example
2449
+ * @example The claim that earns a receipt
2094
2450
  * ```ts
2095
- * const probe = new Probe({ workspace: '/srv/checkout' })
2451
+ * import type { Claim } from '@orkestrel/probe'
2452
+ * import { Probe } from '@orkestrel/probe/server'
2453
+ *
2454
+ * const claim: Claim = {
2455
+ * project: 'configs/src/tsconfig.core.json',
2456
+ * case: {
2457
+ * files: [
2458
+ * {
2459
+ * path: 'src/core/factories.ts',
2460
+ * text: "export function createGreeting(): string {\n\treturn 'hi'\n}\n",
2461
+ * },
2462
+ * ],
2463
+ * test: {
2464
+ * path: 'tmp/probe/greeting.test.ts',
2465
+ * text: "import { expect, test } from 'vitest'\nimport { createGreeting } from '../../src/core/factories.js'\ntest('greets', () => expect(createGreeting()).toBe('hi'))\n",
2466
+ * },
2467
+ * },
2468
+ * control: {
2469
+ * files: [
2470
+ * {
2471
+ * path: 'src/core/factories.ts',
2472
+ * text: "export function createGreeting(): number {\n\treturn 'hi'\n}\n",
2473
+ * },
2474
+ * ],
2475
+ * test: {
2476
+ * path: 'tmp/probe/greeting.test.ts',
2477
+ * text: "import { expect, test } from 'vitest'\nimport { createGreeting } from '../../src/core/factories.js'\ntest('greets', () => expect(createGreeting()).toBe('hi'))\n",
2478
+ * },
2479
+ * stage: 'type',
2480
+ * reason: 'a string returned as a number must not compile',
2481
+ * },
2482
+ * }
2483
+ *
2484
+ * const probe = new Probe({ workspace: process.cwd() })
2096
2485
  * const verdict = await probe.prove(claim)
2486
+ * verdict.digest // 'fcb88a2dee987b8673c1fc7107979470'
2487
+ * verdict.receipt // 'probe:fcb88a2dee987b8673c1fc7107979470:type:typescript@6.0.3:oxlint@1.83.0:vitest@4.1.11:configs/src/tsconfig.core.json@434f59254d58cf2683d453a26bd0d837'
2097
2488
  * await probe.destroy()
2098
2489
  * ```
2099
2490
  */
@@ -2114,7 +2505,7 @@ var Probe = class {
2114
2505
  #arming;
2115
2506
  #closing;
2116
2507
  /**
2117
- * Resolves the target toolchain and starts warming the resident stages.
2508
+ * Resolves the target toolchain and starts warming every stage.
2118
2509
  *
2119
2510
  * @param options - Workspace, deadline, and initial observation hooks
2120
2511
  */
@@ -2279,7 +2670,7 @@ var Probe = class {
2279
2670
  files: [],
2280
2671
  test: typeTest,
2281
2672
  stage: "type",
2282
- reason: "the imported type changed after the resident type host cached it"
2673
+ reason: "the imported type changed on disk between the two inspections"
2283
2674
  }
2284
2675
  };
2285
2676
  const runtimeClaim = {
@@ -2526,12 +2917,16 @@ var Probe = class {
2526
2917
  //#endregion
2527
2918
  //#region src/server/ProbeServer.ts
2528
2919
  /**
2529
- * Serves one probe over this process's Model Context Protocol stdio transport.
2920
+ * Implements `ProbeServerInterface` over a `PassThrough` stream this server owns, binding the
2921
+ * published `prove` tool and the dual-era dispatcher to this process's Model Context Protocol
2922
+ * stdio transport.
2530
2923
  *
2531
2924
  * @remarks
2532
- * Construction creates the probe, publishes the `prove` tool, and binds the dual-era dispatcher to
2533
- * the stdio transport. The probe begins warming there, so a harness that spawns the entry pays
2534
- * arming while its client is still handshaking.
2925
+ * Construction snapshots the probe options, publishes the `prove` tool, and binds the dual-era
2926
+ * dispatcher to the stdio transport. An admitted `prove` call creates the probe and begins
2927
+ * workspace arming, so discovery does not depend on the workspace toolchain. Construction is held
2928
+ * before it begins, so teardown entered through a construction callback waits for the resulting
2929
+ * probe and releases it. A constructor refusal clears that held operation for a later retry.
2535
2930
  *
2536
2931
  * `start` seizes standard input and registers the signals a harness ends a child with, and records
2537
2932
  * whether standard input was already flowing. Every listener it attaches is held as a field, and
@@ -2553,7 +2948,7 @@ var Probe = class {
2553
2948
  * ```
2554
2949
  */
2555
2950
  var ProbeServer = class {
2556
- #probe;
2951
+ #options;
2557
2952
  #stream;
2558
2953
  #transport;
2559
2954
  #data;
@@ -2562,14 +2957,25 @@ var ProbeServer = class {
2562
2957
  #signal;
2563
2958
  #server;
2564
2959
  #owns;
2960
+ #construction;
2565
2961
  #closing;
2566
2962
  /**
2567
- * Creates the probe this server publishes and binds it to this process's stdio transport.
2963
+ * Snapshots the probe options this server will use and binds the published schema to this
2964
+ * process's stdio transport. An admitted `prove` call creates the probe.
2568
2965
  *
2569
- * @param options - Workspace, deadline, and initial observation hooks for the probe it creates
2966
+ * @param options - Workspace, deadline, and initial observation hooks for the deferred probe
2570
2967
  */
2571
2968
  constructor(options) {
2572
- this.#probe = new Probe(options);
2969
+ const workspace = options?.workspace;
2970
+ const deadline = options?.deadline;
2971
+ const on = options?.on;
2972
+ const error = options?.error;
2973
+ this.#options = Object.freeze({
2974
+ workspace: resolve(workspace ?? process.cwd()),
2975
+ ...deadline === void 0 ? {} : { deadline },
2976
+ ...on === void 0 ? {} : { on: Object.freeze({ ...on }) },
2977
+ ...error === void 0 ? {} : { error }
2978
+ });
2573
2979
  this.#stream = new PassThrough();
2574
2980
  this.#server = this.#publish();
2575
2981
  this.#transport = createStdioServer(createMCPLegacy(this.#server), { input: this.#stream });
@@ -2604,7 +3010,10 @@ var ProbeServer = class {
2604
3010
  if (this.#owns === true && process.stdin.listenerCount("data") === 0) process.stdin.pause();
2605
3011
  this.#owns = void 0;
2606
3012
  this.#stream.destroy();
2607
- await this.#probe.destroy();
3013
+ const construction = this.#construction;
3014
+ if (construction === void 0) return;
3015
+ const probe = await construction.catch(() => void 0);
3016
+ if (probe !== void 0) await probe.destroy();
2608
3017
  }
2609
3018
  #receive(chunk) {
2610
3019
  this.#stream.write(chunk);
@@ -2650,7 +3059,20 @@ var ProbeServer = class {
2650
3059
  context: { value: input }
2651
3060
  });
2652
3061
  }
2653
- return this.#probe.prove(input);
3062
+ return (await this.#resolveProbe()).prove(input);
3063
+ }
3064
+ async #resolveProbe() {
3065
+ if (this.#closing !== void 0) throw createDestroyedError("probe server");
3066
+ const current = this.#construction;
3067
+ if (current !== void 0) return await current;
3068
+ const construction = Promise.resolve().then(() => new Probe(this.#options));
3069
+ this.#construction = construction;
3070
+ try {
3071
+ return await construction;
3072
+ } catch (error) {
3073
+ if (this.#construction === construction) this.#construction = void 0;
3074
+ throw error;
3075
+ }
2654
3076
  }
2655
3077
  async #execute(context) {
2656
3078
  const result = await context.tools.execute(context.call);
@@ -2692,6 +3114,6 @@ var ProbeServer = class {
2692
3114
  }
2693
3115
  };
2694
3116
  //#endregion
2695
- export { LintStage, Overlay, Probe, ProbeServer, RuntimeStage, TypeStage, buildRevisionPath, captureListeners, computeDigest, describeUnknown, escapesRoot, findRefusedPaths, guardStage, inferDocumentLanguage, inferTestProject, inferTypeProject, isRefusedName, loadWorkspaceModule, matchesWorkspaceModule, normalizePath, normalizeValue, overwriteFile, readFaultCode, readWorkspaceManifest, relativeWorkspaceFile, relativeWorkspaceMessage, releaseListeners, resolveWorkspaceBinary, resolveWorkspaceFile, resolveWorkspaceModule };
3117
+ 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 };
2696
3118
 
2697
3119
  //# sourceMappingURL=index.js.map