@orkestrel/probe 0.0.12 → 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, 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.13";
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
  };
@@ -1165,7 +1390,7 @@ var RuntimeStage = class {
1165
1390
  */
1166
1391
  constructor(workspace = process.cwd()) {
1167
1392
  this.#workspace = workspace;
1168
- const vitest = loadWorkspaceModule(this.#workspace, "vitest/node");
1393
+ const vitest = loadWorkspaceVitest(this.#workspace);
1169
1394
  this.#store(this.#warm(vitest.createVitest));
1170
1395
  }
1171
1396
  get stage() {
@@ -1174,9 +1399,29 @@ var RuntimeStage = class {
1174
1399
  get progress() {
1175
1400
  return this.#progress;
1176
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
+ */
1177
1409
  inspect(subject) {
1178
1410
  return guardStage(this.stage, this.#inspect(subject));
1179
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
+ */
1180
1425
  destroy() {
1181
1426
  if (this.#closing !== void 0) return this.#closing;
1182
1427
  this.#closing = guardStage(this.stage, this.#destroy());
@@ -1520,13 +1765,13 @@ var RuntimeStage = class {
1520
1765
  const current = this.#vitest;
1521
1766
  if (current !== void 0 && this.#specifications < PROBE_SPECIFICATIONS) return current;
1522
1767
  if (current !== void 0) return this.#store(this.#replace(current));
1523
- const runner = loadWorkspaceModule(this.#workspace, "vitest/node");
1768
+ const runner = loadWorkspaceVitest(this.#workspace);
1524
1769
  return this.#store(this.#specifications < PROBE_SPECIFICATIONS ? this.#warm(runner.createVitest) : this.#replace(void 0, runner.createVitest));
1525
1770
  }
1526
1771
  async #replace(current, create) {
1527
1772
  if (current !== void 0) await (await current).close();
1528
1773
  if (this.#closing !== void 0) throw createDestroyedError("runtime stage");
1529
- const runner = create ?? loadWorkspaceModule(this.#workspace, "vitest/node").createVitest;
1774
+ const runner = create ?? loadWorkspaceVitest(this.#workspace).createVitest;
1530
1775
  const replacement = await this.#warm(runner);
1531
1776
  this.#specifications = 0;
1532
1777
  return replacement;
@@ -1570,7 +1815,7 @@ var RuntimeStage = class {
1570
1815
  }
1571
1816
  #snapshot() {
1572
1817
  const modules = /* @__PURE__ */ new Map();
1573
- for (const path of this.#walk()) {
1818
+ for (const path of collectWorkspaceFiles(this.#workspace)) {
1574
1819
  if (!matchesWorkspaceModule(path)) continue;
1575
1820
  try {
1576
1821
  const digest = createHash("sha256").update(readFileSync(path)).digest("hex");
@@ -1581,12 +1826,12 @@ var RuntimeStage = class {
1581
1826
  return modules;
1582
1827
  }
1583
1828
  #sweep() {
1584
- for (const path of this.#walk()) {
1829
+ for (const path of collectWorkspaceFiles(this.#workspace)) {
1585
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);
1586
1831
  const revision = match?.[1];
1587
1832
  const owner = match?.[2];
1588
1833
  if (revision === void 0 || owner === void 0) continue;
1589
- if (this.#alive(Number.parseInt(owner, 10))) continue;
1834
+ if (matchesLiveProcess(Number.parseInt(owner, 10))) continue;
1590
1835
  if (!this.#owned(path, revision)) continue;
1591
1836
  try {
1592
1837
  unlinkSync(resolveWorkspaceFile(this.#workspace, relative(this.#workspace, path), true));
@@ -1597,35 +1842,6 @@ var RuntimeStage = class {
1597
1842
  const outcome = attempt(() => readFileSync(path, "utf8"));
1598
1843
  return outcome.success && matchesSpecification(outcome.value, revision);
1599
1844
  }
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
1845
  #issues(result, file, original) {
1630
1846
  const specification = this.#real(file);
1631
1847
  const issues = [];
@@ -1741,21 +1957,32 @@ var RuntimeStage = class {
1741
1957
  //#endregion
1742
1958
  //#region src/server/stages/TypeStage.ts
1743
1959
  /**
1744
- * 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.
1745
1961
  *
1746
1962
  * @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.
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.
1759
1986
  *
1760
1987
  * @example
1761
1988
  * ```ts
@@ -1766,26 +1993,27 @@ var RuntimeStage = class {
1766
1993
  */
1767
1994
  var TypeStage = class {
1768
1995
  #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;
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;
1777
2004
  #closing;
1778
2005
  #progress = 0;
1779
2006
  /**
1780
- * Starts warming the target workspace's TypeScript service.
2007
+ * Resolves the target workspace's compiler and starts warming its mirror.
1781
2008
  *
1782
2009
  * @param workspace - The target workspace root. Default: the current working directory
1783
2010
  */
1784
2011
  constructor(workspace = process.cwd()) {
1785
2012
  this.#workspace = workspace;
1786
- const typescript = loadWorkspaceModule(this.#workspace, "typescript");
1787
- this.#typescript = this.#warm(typescript);
1788
- 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(() => {});
1789
2017
  }
1790
2018
  get stage() {
1791
2019
  return "type";
@@ -1805,7 +2033,8 @@ var TypeStage = class {
1805
2033
  * @param project - The workspace-relative TypeScript project the candidate drafts are checked
1806
2034
  * against. Default: the scoped project each candidate path infers
1807
2035
  * @returns One outcome for this stage
1808
- * @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
1809
2038
  */
1810
2039
  inspect(subject, project) {
1811
2040
  return guardStage(this.stage, this.#inspect(subject, project));
@@ -1814,15 +2043,15 @@ var TypeStage = class {
1814
2043
  * Resolves one project to the path and digest this stage applies for it.
1815
2044
  *
1816
2045
  * @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.
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.
1821
2050
  *
1822
2051
  * @param project - The workspace-relative TypeScript project to resolve
1823
2052
  * @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
2053
+ * @throws When the project escapes the workspace, when the compiler refuses it, or when the
2054
+ * stage has already been destroyed
1826
2055
  */
1827
2056
  resolve(project) {
1828
2057
  return guardStage(this.stage, this.#resolve(project));
@@ -1833,81 +2062,76 @@ var TypeStage = class {
1833
2062
  return this.#closing;
1834
2063
  }
1835
2064
  async #inspect(subject, project) {
1836
- if (this.#closing !== void 0) throw createDestroyedError("type stage");
2065
+ this.#refuseDestroyed();
1837
2066
  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) => ({
2067
+ await this.#warming;
2068
+ this.#refuseDestroyed();
2069
+ const test = resolveWorkspaceFile(this.#workspace, subject.test.path);
2070
+ const resolved = subject.files.map((draft) => ({
1841
2071
  draft,
1842
2072
  path: resolveWorkspaceFile(this.#workspace, draft.path)
1843
- })).map((candidate) => ({
1844
- draft: candidate.draft,
1845
- project: project ?? inferTypeProject(relativeWorkspaceFile(this.#workspace, candidate.path))
1846
2073
  }));
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();
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);
1853
2080
  }
1854
- const overlay = new Overlay({ sensitive: this.#caseSensitive(typescript) });
1855
- this.#overlay = overlay;
2081
+ this.#progress += 1;
2082
+ for (const selected of groups.keys()) await this.#configure(selected);
2083
+ this.#refuseDestroyed();
1856
2084
  try {
1857
- this.#record(subject.test, overlay);
1858
- for (const draft of subject.files) this.#record(draft, overlay);
1859
- 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));
1860
2088
  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();
2089
+ for (const [selected, drafts] of groups) {
2090
+ this.#refuseDestroyed();
2091
+ issues.push(...await this.#check(selected, drafts));
1872
2092
  }
1873
2093
  return {
1874
2094
  stage: this.stage,
1875
2095
  elapsed: Math.round(performance.now() - started),
1876
- issues
2096
+ issues: filterUniqueIssues(issues)
1877
2097
  };
1878
2098
  } finally {
1879
- overlay.clear();
2099
+ this.#release();
1880
2100
  }
1881
2101
  }
1882
2102
  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);
2103
+ this.#refuseDestroyed();
2104
+ const contained = this.#contain(project);
1887
2105
  this.#progress += 1;
1888
- this.#service(typescript, project);
1889
- await this.#unblock();
2106
+ const config = await this.#configure(contained);
1890
2107
  return {
1891
- path: relativeWorkspaceFile(this.#workspace, resolved),
1892
- digest: computeDigest(this.#workspace, this.#options.get(resolved) ?? {})
2108
+ path: contained,
2109
+ digest: computeDigest(this.#workspace, config.compilerOptions)
1893
2110
  };
1894
2111
  }
1895
2112
  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;
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, [])));
1911
2135
  }
1912
2136
  #projects() {
1913
2137
  const projects = ["tsconfig.json"];
@@ -1923,162 +2147,291 @@ var TypeStage = class {
1923
2147
  }
1924
2148
  return projects;
1925
2149
  }
1926
- async #unblock() {
1927
- await setTimeout(0);
2150
+ #refuseDestroyed() {
1928
2151
  if (this.#closing !== void 0) throw createDestroyedError("type stage");
1929
2152
  }
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
- }
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"
1946
2159
  });
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
- }
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
2160
  }
1996
- #directoryExists(typescript, directory) {
1997
- return typescript.sys.directoryExists(directory) || this.#overlay.covers(directory);
1998
- }
1999
- #caseSensitive(typescript) {
2000
- 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
+ }
2001
2184
  }
2002
- #newline(typescript) {
2003
- 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
+ }
2004
2202
  }
2005
- #translate(typescript, message) {
2006
- 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
+ }
2007
2242
  }
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());
2243
+ #mirrorPath(contained) {
2244
+ return join(this.#mirror, contained);
2012
2245
  }
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);
2246
+ #contain(project) {
2247
+ return relativeWorkspaceFile(this.#workspace, resolveWorkspaceFile(this.#workspace, project));
2022
2248
  }
2023
- #version(file) {
2024
- if (this.#overlay.text(file) !== void 0) return `virtual:${this.#overlay.revision}`;
2249
+ #remove(contained) {
2025
2250
  try {
2026
- return `disk:${statSync(file).mtimeMs}`;
2027
- } catch {
2028
- return "missing";
2029
- }
2251
+ rmSync(this.#mirrorPath(contained), {
2252
+ force: true,
2253
+ maxRetries: 5,
2254
+ retryDelay: 20
2255
+ });
2256
+ } catch {}
2030
2257
  }
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",
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",
2050
2292
  context: {
2051
2293
  stage: this.stage,
2052
2294
  project
2053
2295
  }
2054
2296
  });
2055
- return {
2056
- origin: "workspace",
2057
- path: project,
2058
- message
2059
- };
2060
2297
  }
2061
- const path = relativeWorkspaceFile(this.#workspace, diagnostic.file.fileName);
2062
- if (diagnostic.start === void 0) return {
2063
- origin: "claimant",
2064
- path,
2065
- 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 }
2066
2318
  };
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))
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
2074
2359
  }
2075
- };
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");
2076
2429
  }
2077
2430
  };
2078
2431
  //#endregion
2079
2432
  //#region src/server/Probe.ts
2080
2433
  /**
2081
- * Answers claims through resident TypeScript, Oxlint, and Vitest stages.
2434
+ * Answers claims through its type, lint, and runtime stages.
2082
2435
  *
2083
2436
  * @remarks
2084
2437
  * Construction resolves the target workspace's toolchain and begins warming every stage. The boot
@@ -2090,10 +2443,45 @@ var TypeStage = class {
2090
2443
  * claim costs that claim rather than the process. A failed boot is replaced the same way: the next
2091
2444
  * claim runs the controls again rather than inheriting a refusal.
2092
2445
  *
2093
- * @example
2446
+ * @example The claim that earns a receipt
2094
2447
  * ```ts
2095
- * 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() })
2096
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'
2097
2485
  * await probe.destroy()
2098
2486
  * ```
2099
2487
  */
@@ -2114,7 +2502,7 @@ var Probe = class {
2114
2502
  #arming;
2115
2503
  #closing;
2116
2504
  /**
2117
- * Resolves the target toolchain and starts warming the resident stages.
2505
+ * Resolves the target toolchain and starts warming every stage.
2118
2506
  *
2119
2507
  * @param options - Workspace, deadline, and initial observation hooks
2120
2508
  */
@@ -2279,7 +2667,7 @@ var Probe = class {
2279
2667
  files: [],
2280
2668
  test: typeTest,
2281
2669
  stage: "type",
2282
- reason: "the imported type changed after the resident type host cached it"
2670
+ reason: "the imported type changed on disk between the two inspections"
2283
2671
  }
2284
2672
  };
2285
2673
  const runtimeClaim = {
@@ -2526,7 +2914,9 @@ var Probe = class {
2526
2914
  //#endregion
2527
2915
  //#region src/server/ProbeServer.ts
2528
2916
  /**
2529
- * 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.
2530
2920
  *
2531
2921
  * @remarks
2532
2922
  * Construction creates the probe, publishes the `prove` tool, and binds the dual-era dispatcher to
@@ -2692,6 +3082,6 @@ var ProbeServer = class {
2692
3082
  }
2693
3083
  };
2694
3084
  //#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 };
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 };
2696
3086
 
2697
3087
  //# sourceMappingURL=index.js.map