@ttsc/unplugin 0.26.1 → 0.27.0

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.
@@ -1,3 +1,4 @@
1
+ import { type ChildProcess, spawn } from "node:child_process";
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import os from "node:os";
@@ -9,6 +10,7 @@ import type {
9
10
  import { TtscCompiler } from "ttsc";
10
11
  import {
11
12
  type FilesystemPathIdentityContext,
13
+ type FilesystemPathIdentityOperations,
12
14
  createFilesystemPathIdentityContext,
13
15
  } from "ttsc/path-identity";
14
16
  import type { TransformResult } from "unplugin";
@@ -43,15 +45,31 @@ export interface TtscTransformAlias {
43
45
  replacement: string;
44
46
  }
45
47
 
48
+ /** One directory's cheap project-membership identity at generation time. */
49
+ interface TtscProjectDirectorySnapshot {
50
+ /** Absolute directory spelling used by the project walk. */
51
+ path: string;
52
+ /** Metadata signature that changes when its immediate membership changes. */
53
+ signature: string;
54
+ }
55
+
56
+ /** Generation-scoped directory watchers used to detect membership changes. */
57
+ interface TtscProjectMutationTracker {
58
+ close: () => void;
59
+ failed: boolean;
60
+ membershipChanged: boolean;
61
+ settle?: Promise<void>;
62
+ }
63
+
46
64
  /**
47
65
  * A single entry in the project transform cache.
48
66
  *
49
67
  * Stores the full compiler result together with SHA-256 hashes of every project
50
68
  * input file. In a cache with an explicit build lifecycle, the first delivery
51
69
  * of each compiled module compares its supplied source with the generation
52
- * snapshot in constant time; a repeated delivery re-hashes the complete input
53
- * set. Persistent caches without that boundary perform complete validation on
54
- * every hit.
70
+ * snapshot in constant time. Later graph-bearing deliveries validate only the
71
+ * requested file's derived inputs plus exact host descriptor/config inputs;
72
+ * graph-free envelopes retain complete-snapshot validation.
55
73
  */
56
74
  export interface TtscCachedProjectTransform {
57
75
  /**
@@ -68,6 +86,12 @@ export interface TtscCachedProjectTransform {
68
86
  * `buildStart` and never replay across edits.
69
87
  */
70
88
  externalInputHashes?: Record<string, string>;
89
+ /**
90
+ * Compiler-time physical identities for graph-owned entries in
91
+ * {@link externalInputHashes}. Dependency-only paths have no generation
92
+ * realpath protocol and therefore omit this evidence.
93
+ */
94
+ externalInputRealpaths?: Record<string, string | null>;
71
95
  /**
72
96
  * Original absolute spellings of {@link externalInputHashes} inputs. These
73
97
  * stay separate from their identity keys so validation reads the paths the
@@ -79,13 +103,25 @@ export interface TtscCachedProjectTransform {
79
103
  * transform.
80
104
  */
81
105
  inputHashes: Record<string, string>;
106
+ /** Metadata snapshot of every directory in the stable generation walk. */
107
+ projectDirectories?: TtscProjectDirectorySnapshot[];
108
+ /** Live notification state for universal host-input changes. */
109
+ hostInputMutationTracker?: TtscProjectMutationTracker;
110
+ /** Live notification state for file/directory creation, deletion, and rename. */
111
+ projectMutationTracker?: TtscProjectMutationTracker;
112
+ /**
113
+ * Whether the generation-time project walk observed every directory and file
114
+ * it attempted to snapshot. An incomplete walk may never authorize narrow
115
+ * validation; a later complete walk must be allowed to replace it.
116
+ */
117
+ projectSnapshotComplete?: boolean;
82
118
  /** Absolute path to the directory that owns the tsconfig. */
83
119
  projectRoot: string;
84
120
  /** Raw compiler output returned by {@link TtscCompiler.transform}. */
85
121
  result: ITtscCompilerTransformation;
86
122
  /**
87
123
  * Files already delivered from this generation, keyed by filesystem identity.
88
- * Build-scoped caches use this to skip complete validation only for a
124
+ * Build-scoped caches use this to skip persistent validation only for a
89
125
  * module's first delivery inside the current build.
90
126
  */
91
127
  servedFiles?: Set<string>;
@@ -111,21 +147,112 @@ export type TtscTransformCache = Map<
111
147
  Promise<TtscCachedProjectTransform>
112
148
  >;
113
149
 
150
+ /** Cache-owned synchronous filesystem reads used by transform validation. */
151
+ export interface TtscTransformFilesystemOperations {
152
+ /** Override the case policy when the observed filesystem is not the host. */
153
+ caseSensitive?: FilesystemPathIdentityOperations["caseSensitive"];
154
+ /** Test whether a validation or resolution candidate currently exists. */
155
+ exists(location: string): boolean;
156
+ /** Read link metadata without following a symbolic link. */
157
+ lstat(location: string): fs.BigIntStats;
158
+ /** Read bytes used by project, graph, and host-input fingerprints. */
159
+ readFile(location: string): Buffer;
160
+ /** Enumerate one project or missing-input proof directory. */
161
+ readdir(location: string): fs.Dirent[];
162
+ /** Resolve one lexical path to its current physical target. */
163
+ realpath(location: string): string;
164
+ /** Read ordinary metadata for file-kind and missing-path checks. */
165
+ stat(location: string): fs.Stats;
166
+ /** Read nanosecond metadata for stable file and directory signatures. */
167
+ statBigInt(location: string): fs.BigIntStats;
168
+ /** Override path parsing when the observed filesystem is not the host. */
169
+ platform?: NodeJS.Platform;
170
+ }
171
+
172
+ const DEFAULT_FILESYSTEM_OPERATIONS: TtscTransformFilesystemOperations =
173
+ Object.freeze({
174
+ exists: fs.existsSync,
175
+ lstat: (location: string) => fs.lstatSync(location, { bigint: true }),
176
+ readFile: (location: string) => fs.readFileSync(location),
177
+ readdir: (location: string) =>
178
+ fs.readdirSync(location, { withFileTypes: true }),
179
+ realpath: fs.realpathSync.native,
180
+ stat: fs.statSync,
181
+ statBigInt: (location: string) => fs.statSync(location, { bigint: true }),
182
+ });
183
+
184
+ const TRANSFORM_CACHE_FILESYSTEM = new WeakMap<
185
+ TtscTransformCache,
186
+ TtscTransformFilesystemOperations
187
+ >();
188
+ const TRANSFORM_RESULT_FILESYSTEM = new WeakMap<
189
+ ITtscCompilerTransformation,
190
+ TtscTransformFilesystemOperations
191
+ >();
192
+
114
193
  /**
115
194
  * Caches whose owner has declared a real per-build lifecycle by calling
116
195
  * {@link beginTtscTransformBuild} before transforms begin.
117
196
  */
118
197
  const BUILD_SCOPED_TRANSFORM_CACHES = new WeakSet<TtscTransformCache>();
119
198
 
120
- function createHostPathIdentityContext(): FilesystemPathIdentityContext {
199
+ function createHostPathIdentityContext(
200
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
201
+ ): FilesystemPathIdentityContext {
121
202
  return createFilesystemPathIdentityContext({
203
+ caseSensitive: filesystem.caseSensitive,
204
+ lstat: filesystem.lstat,
205
+ platform: filesystem.platform,
206
+ readdir: (directory) =>
207
+ filesystem.readdir(directory).map((entry) => entry.name),
208
+ realpath: filesystem.realpath,
122
209
  throwOnRealpathError: false,
123
210
  });
124
211
  }
125
212
 
126
- /** Create an empty persistent transform cache. */
127
- export function createTtscTransformCache(): TtscTransformCache {
128
- return new Map();
213
+ /** Normalize one directory entry under the owning filesystem's case policy. */
214
+ export function normalizeHostInputName(
215
+ name: string,
216
+ caseSensitive: boolean,
217
+ ): string {
218
+ return caseSensitive ? name : name.toLowerCase();
219
+ }
220
+
221
+ /** Create an empty persistent transform cache with isolated filesystem reads. */
222
+ export function createTtscTransformCache(
223
+ operations: Partial<TtscTransformFilesystemOperations> = {},
224
+ ): TtscTransformCache {
225
+ const cache: TtscTransformCache = new Map();
226
+ TRANSFORM_CACHE_FILESYSTEM.set(cache, {
227
+ caseSensitive: operations.caseSensitive,
228
+ exists: operations.exists ?? DEFAULT_FILESYSTEM_OPERATIONS.exists,
229
+ lstat: operations.lstat ?? DEFAULT_FILESYSTEM_OPERATIONS.lstat,
230
+ readFile: operations.readFile ?? DEFAULT_FILESYSTEM_OPERATIONS.readFile,
231
+ readdir: operations.readdir ?? DEFAULT_FILESYSTEM_OPERATIONS.readdir,
232
+ realpath: operations.realpath ?? DEFAULT_FILESYSTEM_OPERATIONS.realpath,
233
+ stat: operations.stat ?? DEFAULT_FILESYSTEM_OPERATIONS.stat,
234
+ statBigInt:
235
+ operations.statBigInt ?? DEFAULT_FILESYSTEM_OPERATIONS.statBigInt,
236
+ platform: operations.platform,
237
+ });
238
+ return cache;
239
+ }
240
+
241
+ function transformFilesystem(
242
+ cache: TtscTransformCache | undefined,
243
+ ): TtscTransformFilesystemOperations {
244
+ return (
245
+ (cache === undefined ? undefined : TRANSFORM_CACHE_FILESYSTEM.get(cache)) ??
246
+ DEFAULT_FILESYSTEM_OPERATIONS
247
+ );
248
+ }
249
+
250
+ function resultFilesystem(
251
+ result: ITtscCompilerTransformation,
252
+ ): TtscTransformFilesystemOperations {
253
+ return (
254
+ TRANSFORM_RESULT_FILESYSTEM.get(result) ?? DEFAULT_FILESYSTEM_OPERATIONS
255
+ );
129
256
  }
130
257
 
131
258
  /**
@@ -137,7 +264,7 @@ export function createTtscTransformCache(): TtscTransformCache {
137
264
  * defines one process-scoped module-loading session.
138
265
  */
139
266
  export function beginTtscTransformBuild(cache: TtscTransformCache): void {
140
- cache.clear();
267
+ clearTtscTransformCache(cache);
141
268
  BUILD_SCOPED_TRANSFORM_CACHES.add(cache);
142
269
  }
143
270
 
@@ -149,10 +276,19 @@ export function beginTtscTransformBuild(cache: TtscTransformCache): void {
149
276
  * many edits, so that callback cannot authorize build-scoped shortcuts.
150
277
  */
151
278
  export function resetTtscTransformCache(cache: TtscTransformCache): void {
152
- cache.clear();
279
+ clearTtscTransformCache(cache);
153
280
  BUILD_SCOPED_TRANSFORM_CACHES.delete(cache);
154
281
  }
155
282
 
283
+ /** Dispose generation-owned filesystem resources before clearing a cache. */
284
+ function clearTtscTransformCache(cache: TtscTransformCache): void {
285
+ const generations = [...cache.values()];
286
+ cache.clear();
287
+ for (const generation of generations) {
288
+ void generation.then(disposeCachedTransform, () => undefined);
289
+ }
290
+ }
291
+
156
292
  /**
157
293
  * Hooks the bundler adapter passes into {@link transformTtsc} so transform
158
294
  * side-channels (plugin-reported dependencies and host resolution candidates)
@@ -215,6 +351,7 @@ export async function transformTtsc(
215
351
  cache?: TtscTransformCache,
216
352
  hooks?: TtscTransformHooks,
217
353
  ): Promise<TtscTransformResult | undefined> {
354
+ const filesystem = transformFilesystem(cache);
218
355
  const clean = stripQuery(id);
219
356
  if (clean.includes("\0")) {
220
357
  return undefined;
@@ -227,7 +364,7 @@ export async function transformTtsc(
227
364
  return undefined;
228
365
  }
229
366
 
230
- const tsconfig = resolveTsconfig(file, options.project);
367
+ const tsconfig = resolveTsconfig(file, options.project, filesystem);
231
368
  const aliasPaths = createAliasPaths(aliases);
232
369
  const key = createTransformCacheKey({
233
370
  aliasPaths,
@@ -242,11 +379,20 @@ export async function transformTtsc(
242
379
  // A rejected in-flight generation must not stay cached: evict it (only if
243
380
  // it is still the current entry) so a later call re-runs the transform.
244
381
  const cached = await awaitOrEvict(cache, key, transformed);
382
+ TRANSFORM_RESULT_FILESYSTEM.set(cached.result, filesystem);
245
383
  // While this caller awaited the old Promise, another caller may have
246
384
  // invalidated it and installed a newer authoritative generation.
247
385
  if (cache?.get(key) !== transformed) {
248
386
  continue;
249
387
  }
388
+ const buildScoped =
389
+ cache !== undefined && BUILD_SCOPED_TRANSFORM_CACHES.has(cache);
390
+ if (!buildScoped) {
391
+ await settleProjectMutationEvents(cached);
392
+ if (cache?.get(key) !== transformed) {
393
+ continue;
394
+ }
395
+ }
250
396
  if (
251
397
  // A file the plugin declared volatile must never be served from the
252
398
  // cache: its output depends on non-file inputs, so the input-hash
@@ -256,12 +402,7 @@ export async function transformTtsc(
256
402
  projectRoot: cached.projectRoot,
257
403
  result: cached.result,
258
404
  }) &&
259
- matchesCachedSource(
260
- cached,
261
- file,
262
- source,
263
- cache !== undefined && BUILD_SCOPED_TRANSFORM_CACHES.has(cache),
264
- )
405
+ matchesCachedSource(cached, file, source, buildScoped)
265
406
  ) {
266
407
  reportSuccessDiagnostics(cached.result);
267
408
  // A resolved `"exception"` / `"failure"` envelope makes this throw;
@@ -296,7 +437,9 @@ export async function transformTtsc(
296
437
  compilerOptions: options.compilerOptions,
297
438
  currentFile: file,
298
439
  currentSource: source,
440
+ filesystem,
299
441
  plugins: options.plugins,
442
+ trackProjectMembership: cache !== undefined,
300
443
  tsconfig,
301
444
  });
302
445
  cache?.set(key, transformed);
@@ -383,9 +526,21 @@ function evictGeneration(
383
526
  ): void {
384
527
  if (cache?.get(key) === generation) {
385
528
  cache.delete(key);
529
+ void generation.then(disposeCachedTransform, () => undefined);
386
530
  }
387
531
  }
388
532
 
533
+ /** Close one generation's directory watchers exactly once. */
534
+ function disposeCachedTransform(cached: TtscCachedProjectTransform): void {
535
+ const trackers = [
536
+ cached.projectMutationTracker,
537
+ cached.hostInputMutationTracker,
538
+ ];
539
+ cached.projectMutationTracker = undefined;
540
+ cached.hostInputMutationTracker = undefined;
541
+ for (const tracker of trackers) tracker?.close();
542
+ }
543
+
389
544
  /**
390
545
  * Per-envelope derivation state: every index the per-delivery paths need, built
391
546
  * at most once and shared by all deliveries of one compiler result.
@@ -431,6 +586,8 @@ interface TtscEnvelopeDerivation {
431
586
  * files, `undefined` until the first completeness predicate.
432
587
  */
433
588
  dependenciesComplete?: Set<string>;
589
+ /** Universal inputs validated once at generation time and then by metadata. */
590
+ hostInputValidation?: TtscHostInputValidation;
434
591
  /**
435
592
  * Lazily built identity -> output source index of the `typescript` map (first
436
593
  * match wins, mirroring the historical scan). `undefined` until the first
@@ -446,6 +603,26 @@ interface TtscEnvelopeDerivation {
446
603
  readonly watchInputs: Map<string, string[]>;
447
604
  }
448
605
 
606
+ interface TtscHostInputValidation {
607
+ /** Lexical input spellings that existed when the generation was captured. */
608
+ readonly entries: Map<
609
+ string,
610
+ {
611
+ path: string;
612
+ realpath: string | null;
613
+ signature: string;
614
+ strict?: true;
615
+ }
616
+ >;
617
+ /** Identities omitted from the per-module dependency loop below. */
618
+ readonly identities: Set<string>;
619
+ /**
620
+ * Missing paths grouped by the nearest directory whose listing proves them
621
+ * absent.
622
+ */
623
+ readonly missing: Map<string, Set<string>>;
624
+ }
625
+
449
626
  /** Reference-graph indexes shared by every watch-input derivation. */
450
627
  interface TtscEnvelopeGraphIndexes {
451
628
  /** Identity of each direct-edge source -> its resolved absolute targets. */
@@ -457,6 +634,15 @@ interface TtscEnvelopeGraphIndexes {
457
634
  /** Resolved absolute `graph.globals` and `graph.configs` members. */
458
635
  readonly globals: string[];
459
636
  readonly configs: string[];
637
+ /** Every realized/candidate graph path, keyed by filesystem identity. */
638
+ readonly members: Set<string>;
639
+ /** Compiler-time proof for graph members, keyed by filesystem identity. */
640
+ readonly inputProofs: Map<
641
+ string,
642
+ { hash: string | null; path: string; realpath: string | null }
643
+ >;
644
+ /** Aliased graph proof keys that reported contradictory generation states. */
645
+ readonly inputProofConflicts: Set<string>;
460
646
  }
461
647
 
462
648
  /**
@@ -479,7 +665,9 @@ function envelopeDerivation(props: {
479
665
  return existing;
480
666
  }
481
667
  const created: TtscEnvelopeDerivation = {
482
- identityContext: createHostPathIdentityContext(),
668
+ identityContext: createHostPathIdentityContext(
669
+ resultFilesystem(props.result),
670
+ ),
483
671
  identities: new Map(),
484
672
  watchInputs: new Map(),
485
673
  };
@@ -508,6 +696,9 @@ function envelopeGraphIndexes(
508
696
  candidates: [],
509
697
  globals: [],
510
698
  configs: [],
699
+ members: new Set(),
700
+ inputProofs: new Map(),
701
+ inputProofConflicts: new Set(),
511
702
  };
512
703
  const graph =
513
704
  props.result.type === "exception" ? undefined : props.result.graph;
@@ -518,6 +709,7 @@ function envelopeGraphIndexes(
518
709
  }
519
710
  const absolute = path.resolve(props.projectRoot, source);
520
711
  const identity = derivationIdentity(state, absolute);
712
+ built.members.add(identity);
521
713
  built.spellings.set(identity, absolute);
522
714
  const entries = built.edges.get(identity) ?? [];
523
715
  entries.push(
@@ -526,23 +718,84 @@ function envelopeGraphIndexes(
526
718
  (target): target is string =>
527
719
  typeof target === "string" && target.length !== 0,
528
720
  )
529
- .map((target) => path.resolve(props.projectRoot, target)),
721
+ .map((target) => {
722
+ const absoluteTarget = path.resolve(props.projectRoot, target);
723
+ built.members.add(derivationIdentity(state, absoluteTarget));
724
+ return absoluteTarget;
725
+ }),
530
726
  );
531
727
  built.edges.set(identity, entries);
532
728
  }
533
729
  built.globals.push(...selectListedFiles(props.projectRoot, graph.globals));
534
730
  built.configs.push(...selectListedFiles(props.projectRoot, graph.configs));
731
+ for (const input of [...built.globals, ...built.configs]) {
732
+ built.members.add(derivationIdentity(state, input));
733
+ }
535
734
  for (const [source, candidates] of Object.entries(graph.candidates ?? {})) {
536
735
  if (!Array.isArray(candidates)) {
537
736
  continue;
538
737
  }
738
+ const sourceIdentity = derivationIdentity(
739
+ state,
740
+ path.resolve(props.projectRoot, source),
741
+ );
742
+ built.members.add(sourceIdentity);
539
743
  built.candidates.push({
540
- source: derivationIdentity(
541
- state,
542
- path.resolve(props.projectRoot, source),
543
- ),
744
+ source: sourceIdentity,
544
745
  files: selectListedFiles(props.projectRoot, candidates),
545
746
  });
747
+ for (const candidate of candidates) {
748
+ if (typeof candidate !== "string" || candidate.length === 0) continue;
749
+ built.members.add(
750
+ derivationIdentity(state, path.resolve(props.projectRoot, candidate)),
751
+ );
752
+ }
753
+ }
754
+ for (const [input, hash] of Object.entries(graph.inputHashes ?? {})) {
755
+ if (
756
+ hash !== null &&
757
+ (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash))
758
+ ) {
759
+ continue;
760
+ }
761
+ if (
762
+ graph.inputRealpaths === undefined ||
763
+ !Object.prototype.hasOwnProperty.call(graph.inputRealpaths, input)
764
+ ) {
765
+ continue;
766
+ }
767
+ const reportedRealpath = graph.inputRealpaths[input];
768
+ if (
769
+ reportedRealpath !== null &&
770
+ (typeof reportedRealpath !== "string" ||
771
+ !path.isAbsolute(reportedRealpath))
772
+ ) {
773
+ continue;
774
+ }
775
+ const absolute = path.resolve(props.projectRoot, input);
776
+ const identity = derivationIdentity(state, absolute);
777
+ if (!built.members.has(identity)) continue;
778
+ const proof = {
779
+ hash,
780
+ path: absolute,
781
+ realpath:
782
+ reportedRealpath === null ? null : path.resolve(reportedRealpath),
783
+ };
784
+ const previous = built.inputProofs.get(identity);
785
+ if (
786
+ previous !== undefined &&
787
+ (previous.hash !== proof.hash ||
788
+ !sameHostInputRealpath(
789
+ previous.realpath,
790
+ proof.realpath,
791
+ state.identityContext,
792
+ ))
793
+ ) {
794
+ built.inputProofs.delete(identity);
795
+ built.inputProofConflicts.add(identity);
796
+ } else if (!built.inputProofConflicts.has(identity)) {
797
+ built.inputProofs.set(identity, proof);
798
+ }
546
799
  }
547
800
  }
548
801
  state.graph = built;
@@ -677,31 +930,65 @@ function deriveWatchInputs(
677
930
  ): string[] {
678
931
  const graph = envelopeGraphIndexes(state, props);
679
932
  const output: string[] = [];
680
- const seen = new Set<string>();
933
+ const physicalSeen = new Set<string>();
934
+ const lexicalSeen = new Set<string>();
681
935
  const excluded = new Set([fileIdentity]);
682
936
  if (props.temporaryTsconfig !== undefined) {
683
937
  excluded.add(derivationIdentity(state, props.temporaryTsconfig));
684
938
  }
685
- for (const absolute of [
686
- ...selectFileDependencies(props),
687
- ...selectGraphInputs(graph, state, {
688
- ...props,
689
- complete:
690
- declaresCompleteDependencies(state, props) &&
691
- !isVolatileFile(state, props),
692
- }),
693
- ...selectResolutionCandidateInputs(graph, state, props),
694
- ]) {
695
- const identity = derivationIdentity(state, absolute);
696
- if (excluded.has(identity) || seen.has(identity)) {
697
- continue;
939
+ const currentSpelling = path.resolve(props.file);
940
+ const temporarySpelling =
941
+ props.temporaryTsconfig === undefined
942
+ ? undefined
943
+ : path.resolve(props.temporaryTsconfig);
944
+ const appendLexical = (input: string): void => {
945
+ const spelling = path.resolve(input);
946
+ if (
947
+ spelling === currentSpelling ||
948
+ spelling === temporarySpelling ||
949
+ lexicalSeen.has(spelling)
950
+ ) {
951
+ return;
698
952
  }
699
- seen.add(identity);
700
- output.push(absolute);
701
- }
953
+ lexicalSeen.add(spelling);
954
+ physicalSeen.add(derivationIdentity(state, input));
955
+ output.push(input);
956
+ };
957
+ const appendPhysical = (input: string): void => {
958
+ const identity = derivationIdentity(state, input);
959
+ if (excluded.has(identity) || physicalSeen.has(identity)) return;
960
+ physicalSeen.add(identity);
961
+ lexicalSeen.add(path.resolve(input));
962
+ output.push(input);
963
+ };
964
+ for (const input of selectFileDependencies(props)) appendLexical(input);
965
+ for (const input of selectGraphInputs(graph, state, {
966
+ ...props,
967
+ complete:
968
+ declaresCompleteDependencies(state, props) &&
969
+ !isVolatileFile(state, props),
970
+ }))
971
+ appendPhysical(input);
972
+ // Resolution candidates, plugin dependencies, and universal host inputs
973
+ // preserve lexical aliases. Physical deduplication would collapse
974
+ // `alias/selection.cjs` into the selected target path, so a bundler would
975
+ // watch only the target and miss a symlink/junction retarget.
976
+ for (const input of selectResolutionCandidateInputs(graph, state, props))
977
+ appendLexical(input);
978
+ for (const input of selectHostInputs(props)) appendLexical(input);
702
979
  return output;
703
980
  }
704
981
 
982
+ /** Return exact host-wide descriptor/config inputs for every output file. */
983
+ function selectHostInputs(props: {
984
+ projectRoot: string;
985
+ result: ITtscCompilerTransformation;
986
+ }): string[] {
987
+ return props.result.type === "exception"
988
+ ? []
989
+ : selectListedFiles(props.projectRoot, props.result.hostInputs);
990
+ }
991
+
705
992
  /**
706
993
  * Return the module-resolution paths that can supersede a currently resolved
707
994
  * module reachable from `file`. They remain host-owned even when a plugin
@@ -1039,14 +1326,13 @@ export function createTransformResult(
1039
1326
  *
1040
1327
  * Always compares the current module's in-memory source with the generation
1041
1328
  * snapshot. A cache whose owner called {@link beginTtscTransformBuild} can use
1042
- * that comparison alone for the module's first delivery in the current build;
1043
- * repeated requests re-hash every project and out-of-walk input. Persistent
1044
- * caches with no guaranteed build boundary perform complete validation on every
1045
- * hit. Any mismatch forces a complete re-transform.
1046
- *
1047
- * The complete validation snapshot and {@link collectInputHashes} draw their
1048
- * keys from the exact same {@link collectProjectInputHashes} walk, so the two
1049
- * agree on the key universe.
1329
+ * that comparison alone for a stable generation's first module delivery in the
1330
+ * current build. An incomplete generation may not take this shortcut: otherwise
1331
+ * a sibling output captured during a filesystem race could still be served
1332
+ * once. Later graph-bearing requests validate the file's derived input set and
1333
+ * project membership; graph-free envelopes conservatively re-hash the complete
1334
+ * project and out-of-walk snapshots. Any mismatch forces a complete
1335
+ * re-transform.
1050
1336
  */
1051
1337
  function matchesCachedSource(
1052
1338
  cached: TtscCachedProjectTransform,
@@ -1061,16 +1347,410 @@ function matchesCachedSource(
1061
1347
  }
1062
1348
  if (
1063
1349
  buildScoped &&
1350
+ cached.projectSnapshotComplete === true &&
1064
1351
  !cached.servedFiles?.has(pathIdentityKey(file, identities))
1065
1352
  ) {
1066
1353
  return true;
1067
1354
  }
1068
- const currentHashes = collectProjectInputHashes(
1355
+ if (
1356
+ cached.result.type !== "exception" &&
1357
+ cached.result.graph !== undefined &&
1358
+ cached.projectSnapshotComplete === true &&
1359
+ cached.projectDirectories !== undefined &&
1360
+ cached.projectMutationTracker !== undefined &&
1361
+ cached.hostInputMutationTracker !== undefined
1362
+ ) {
1363
+ return matchesNarrowPersistentInputs(cached, file);
1364
+ }
1365
+ return matchesCompleteInputSnapshot(cached, currentKey, source);
1366
+ }
1367
+
1368
+ /**
1369
+ * Validate one graph-bearing cached output against only the inputs that can
1370
+ * affect that file. Project membership is validated once per event-loop turn,
1371
+ * so sibling module deliveries share one directory-metadata pass instead of
1372
+ * multiplying it by module count.
1373
+ */
1374
+ function matchesNarrowPersistentInputs(
1375
+ cached: TtscCachedProjectTransform,
1376
+ file: string,
1377
+ ): boolean {
1378
+ if (!matchesProjectMembership(cached)) {
1379
+ return false;
1380
+ }
1381
+ const hostTracker = cached.hostInputMutationTracker;
1382
+ if (
1383
+ hostTracker === undefined ||
1384
+ hostTracker.failed ||
1385
+ hostTracker.membershipChanged
1386
+ ) {
1387
+ return false;
1388
+ }
1389
+ const state = envelopeDerivation(cached);
1390
+ const hostValidation = state.hostInputValidation;
1391
+ if (
1392
+ hostValidation === undefined ||
1393
+ !matchesUniversalHostInputs(cached, hostValidation)
1394
+ ) {
1395
+ return false;
1396
+ }
1397
+ const inputs = selectWatchInputs({
1398
+ file,
1399
+ projectRoot: cached.projectRoot,
1400
+ result: cached.result,
1401
+ temporaryTsconfig: cached.temporaryTsconfig,
1402
+ });
1403
+ for (const input of inputs) {
1404
+ if (hostValidation.identities.has(derivationIdentity(state, input))) {
1405
+ continue;
1406
+ }
1407
+ if (!matchesRecordedInput(cached, input)) {
1408
+ return false;
1409
+ }
1410
+ }
1411
+ return true;
1412
+ }
1413
+
1414
+ /**
1415
+ * Validate universal descriptor/config inputs without re-reading them for every
1416
+ * module. Existing paths use the same nanosecond metadata manifest that guards
1417
+ * GOROOT identity memoization; missing probes are grouped by the nearest
1418
+ * existing directory and checked through one exact membership listing.
1419
+ */
1420
+ function matchesUniversalHostInputs(
1421
+ cached: TtscCachedProjectTransform,
1422
+ validation: TtscHostInputValidation,
1423
+ ): boolean {
1424
+ const filesystem = resultFilesystem(cached.result);
1425
+ for (const entry of validation.entries.values()) {
1426
+ const signature = inputMetadataSignature(entry.path, filesystem);
1427
+ if (signature === entry.signature) continue;
1428
+ if (entry.strict === true) return false;
1429
+ if (hostInputRealpath(entry.path, filesystem) !== entry.realpath)
1430
+ return false;
1431
+ if (!matchesRecordedInput(cached, entry.path)) {
1432
+ return false;
1433
+ }
1434
+ if (signature === undefined) return false;
1435
+ entry.signature = signature;
1436
+ }
1437
+ for (const [directory, names] of validation.missing) {
1438
+ let entries: fs.Dirent[];
1439
+ try {
1440
+ entries = filesystem.readdir(directory);
1441
+ } catch (error) {
1442
+ // Only a provably absent/non-directory ancestor keeps every descendant
1443
+ // unreachable. Permission and transient I/O failures cannot prove that
1444
+ // a candidate is still missing, while replacing the proving directory
1445
+ // with an exact file can itself redirect module resolution.
1446
+ try {
1447
+ if (!filesystem.stat(directory).isDirectory()) return false;
1448
+ } catch (statError) {
1449
+ if (!isMissingPathError(statError)) return false;
1450
+ continue;
1451
+ }
1452
+ return false;
1453
+ }
1454
+ const identities = envelopeDerivation(cached).identityContext;
1455
+ const caseSensitive = identities.caseSensitive(directory);
1456
+ if (
1457
+ entries.some((entry) =>
1458
+ names.has(normalizeHostInputName(entry.name, caseSensitive)),
1459
+ )
1460
+ ) {
1461
+ return false;
1462
+ }
1463
+ }
1464
+ return true;
1465
+ }
1466
+
1467
+ /** True only for errors that prove a path cannot currently be traversed. */
1468
+ function isMissingPathError(error: unknown): boolean {
1469
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
1470
+ return code === "ENOENT" || code === "ENOTDIR";
1471
+ }
1472
+
1473
+ /** Capture the universal-input manifest while the generation is still fresh. */
1474
+ function captureUniversalHostInputValidation(
1475
+ cached: TtscCachedProjectTransform,
1476
+ currentFile: string,
1477
+ ): TtscHostInputValidation | undefined {
1478
+ const filesystem = resultFilesystem(cached.result);
1479
+ const state = envelopeDerivation(cached);
1480
+ const validation: TtscHostInputValidation = {
1481
+ entries: new Map(),
1482
+ identities: new Set(),
1483
+ missing: new Map(),
1484
+ };
1485
+ for (const input of selectPersistentHostInputs({
1486
+ filesystem,
1487
+ projectRoot: cached.projectRoot,
1488
+ result: cached.result,
1489
+ temporaryTsconfig: cached.temporaryTsconfig,
1490
+ })) {
1491
+ const generationHashes =
1492
+ cached.result.type === "exception"
1493
+ ? undefined
1494
+ : cached.result.hostInputHashes;
1495
+ const generationRealpaths =
1496
+ cached.result.type === "exception"
1497
+ ? undefined
1498
+ : cached.result.hostInputRealpaths;
1499
+ const expected = generationHashes?.[path.resolve(input)];
1500
+ // Every persistent universal input must carry an evaluation-time
1501
+ // fingerprint. If a plugin/native host cannot provide one, keep the fresh
1502
+ // result but decline narrow long-lived reuse.
1503
+ if (expected === undefined) {
1504
+ const current = path.resolve(currentFile);
1505
+ if (path.resolve(input) !== current) return undefined;
1506
+ // The current module may be supplied from an unsaved editor buffer. Its
1507
+ // generation snapshot is overlaid below from `currentSource`, so a disk
1508
+ // fingerprint would be both unavailable and the wrong authority.
1509
+ } else if (expected !== hostInputStateHash(input, filesystem)) {
1510
+ return undefined;
1511
+ }
1512
+ const absoluteInput = path.resolve(input);
1513
+ if (generationRealpaths !== undefined) {
1514
+ if (
1515
+ !Object.prototype.hasOwnProperty.call(
1516
+ generationRealpaths,
1517
+ absoluteInput,
1518
+ ) ||
1519
+ !sameHostInputRealpath(
1520
+ generationRealpaths[absoluteInput],
1521
+ hostInputRealpath(input, filesystem),
1522
+ state.identityContext,
1523
+ )
1524
+ ) {
1525
+ return undefined;
1526
+ }
1527
+ }
1528
+ const identity = derivationIdentity(state, input);
1529
+ validation.identities.add(identity);
1530
+ const before = inputMetadataSignature(input, filesystem);
1531
+ if (!matchesRecordedInput(cached, input)) return undefined;
1532
+ const after = inputMetadataSignature(input, filesystem);
1533
+ if (before !== after) return undefined;
1534
+ if (after !== undefined) {
1535
+ // Do not key this manifest by physical identity. A symlink/junction
1536
+ // spelling and its selected target deliberately share that identity,
1537
+ // but both lexical paths must survive so retargeting the alias is visible.
1538
+ validation.entries.set(path.resolve(input), {
1539
+ path: input,
1540
+ realpath: hostInputRealpath(input, filesystem),
1541
+ signature: after,
1542
+ });
1543
+ continue;
1544
+ }
1545
+ const probe = missingPathProbe(input, filesystem);
1546
+ if (probe.blocker !== undefined) {
1547
+ const blockerIdentity = derivationIdentity(state, probe.blocker);
1548
+ const signature = inputMetadataSignature(probe.blocker, filesystem);
1549
+ if (signature === undefined) return undefined;
1550
+ validation.identities.add(blockerIdentity);
1551
+ validation.entries.set(path.resolve(probe.blocker), {
1552
+ path: probe.blocker,
1553
+ realpath: hostInputRealpath(probe.blocker, filesystem),
1554
+ signature,
1555
+ strict: true,
1556
+ });
1557
+ continue;
1558
+ }
1559
+ let names = validation.missing.get(probe.directory);
1560
+ if (names === undefined) {
1561
+ names = new Set<string>();
1562
+ validation.missing.set(probe.directory, names);
1563
+ }
1564
+ names.add(
1565
+ normalizeHostInputName(
1566
+ probe.name,
1567
+ state.identityContext.caseSensitive(probe.directory),
1568
+ ),
1569
+ );
1570
+ }
1571
+ state.hostInputValidation = validation;
1572
+ return validation;
1573
+ }
1574
+
1575
+ /** Metadata identity whose stability lets a generation reuse a content hash. */
1576
+ function inputMetadataSignature(
1577
+ file: string,
1578
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
1579
+ ): string | undefined {
1580
+ try {
1581
+ const link = filesystem.lstat(file);
1582
+ let target = link;
1583
+ if (link.isSymbolicLink()) {
1584
+ try {
1585
+ target = filesystem.statBigInt(file);
1586
+ } catch {
1587
+ // Keep a broken link in the existing-input manifest. Its own metadata
1588
+ // stays stable while the target is missing, and the first successful
1589
+ // stat after the target appears changes this signature. Treating it as
1590
+ // a plain missing path would watch/list only the link's parent, which
1591
+ // cannot observe a target created in another directory.
1592
+ return [
1593
+ link.dev,
1594
+ link.ino,
1595
+ link.mode,
1596
+ link.size,
1597
+ link.mtimeNs,
1598
+ link.ctimeNs,
1599
+ "missing-target",
1600
+ ].join(":");
1601
+ }
1602
+ }
1603
+ return [
1604
+ link.dev,
1605
+ link.ino,
1606
+ link.mode,
1607
+ link.size,
1608
+ link.mtimeNs,
1609
+ link.ctimeNs,
1610
+ target.dev,
1611
+ target.ino,
1612
+ target.mode,
1613
+ target.size,
1614
+ target.mtimeNs,
1615
+ target.ctimeNs,
1616
+ ].join(":");
1617
+ } catch {
1618
+ return undefined;
1619
+ }
1620
+ }
1621
+
1622
+ /** Content/kind fingerprint matching the compiler host-input contract. */
1623
+ function hostInputStateHash(
1624
+ file: string,
1625
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
1626
+ ): string | null {
1627
+ try {
1628
+ return hashText(filesystem.readFile(file));
1629
+ } catch {
1630
+ try {
1631
+ return filesystem.stat(file).isDirectory()
1632
+ ? hashText("ttsc:host-input:directory\0")
1633
+ : null;
1634
+ } catch {
1635
+ return null;
1636
+ }
1637
+ }
1638
+ }
1639
+
1640
+ /** Fingerprint the text/kind state returned by TypeScript-Go's filesystem. */
1641
+ function graphInputStateHash(
1642
+ file: string,
1643
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
1644
+ ): string | null {
1645
+ try {
1646
+ const bytes = filesystem.readFile(file);
1647
+ if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
1648
+ const even = bytes.subarray(
1649
+ 2,
1650
+ 2 + Math.floor((bytes.length - 2) / 2) * 2,
1651
+ );
1652
+ return hashText(Buffer.from(even.toString("utf16le"), "utf8"));
1653
+ }
1654
+ if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
1655
+ const even = Buffer.from(
1656
+ bytes.subarray(2, 2 + Math.floor((bytes.length - 2) / 2) * 2),
1657
+ );
1658
+ even.swap16();
1659
+ return hashText(Buffer.from(even.toString("utf16le"), "utf8"));
1660
+ }
1661
+ const content =
1662
+ bytes.length >= 3 &&
1663
+ bytes[0] === 0xef &&
1664
+ bytes[1] === 0xbb &&
1665
+ bytes[2] === 0xbf
1666
+ ? bytes.subarray(3)
1667
+ : bytes;
1668
+ return hashText(content);
1669
+ } catch {
1670
+ try {
1671
+ return filesystem.stat(file).isDirectory()
1672
+ ? hashText("ttsc:host-input:directory\0")
1673
+ : null;
1674
+ } catch {
1675
+ return null;
1676
+ }
1677
+ }
1678
+ }
1679
+
1680
+ /** Physical target selected by a lexical host-input path. */
1681
+ function hostInputRealpath(
1682
+ file: string,
1683
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
1684
+ ): string | null {
1685
+ try {
1686
+ return filesystem.realpath(file);
1687
+ } catch {
1688
+ return null;
1689
+ }
1690
+ }
1691
+
1692
+ /** Compare two reported realpaths by filesystem identity, not Windows spelling. */
1693
+ function sameHostInputRealpath(
1694
+ left: string | null | undefined,
1695
+ right: string | null,
1696
+ identities: FilesystemPathIdentityContext,
1697
+ ): boolean {
1698
+ if (left === undefined || (left === null) !== (right === null)) return false;
1699
+ if (left === null || right === null) return true;
1700
+ return (
1701
+ pathIdentityKey(left, identities) === pathIdentityKey(right, identities)
1702
+ );
1703
+ }
1704
+
1705
+ /** Find one directory listing that proves an absent path is still absent. */
1706
+ function missingPathProbe(
1707
+ file: string,
1708
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
1709
+ ): {
1710
+ blocker?: string;
1711
+ directory: string;
1712
+ name: string;
1713
+ } {
1714
+ let child = path.resolve(file);
1715
+ for (;;) {
1716
+ const directory = path.dirname(child);
1717
+ try {
1718
+ const stats = filesystem.stat(directory);
1719
+ if (stats.isDirectory()) {
1720
+ return { directory, name: path.basename(child) };
1721
+ }
1722
+ return {
1723
+ blocker: directory,
1724
+ directory: path.dirname(directory),
1725
+ name: path.basename(directory),
1726
+ };
1727
+ } catch {}
1728
+ if (directory === child) {
1729
+ return { directory, name: path.basename(child) };
1730
+ }
1731
+ child = directory;
1732
+ }
1733
+ }
1734
+
1735
+ /** Fall back to the historical whole-envelope validation without a graph. */
1736
+ function matchesCompleteInputSnapshot(
1737
+ cached: TtscCachedProjectTransform,
1738
+ currentKey: string,
1739
+ source: string,
1740
+ ): boolean {
1741
+ if (cached.projectSnapshotComplete !== true) {
1742
+ return false;
1743
+ }
1744
+ const current = collectProjectInputSnapshot(
1069
1745
  cached.projectRoot,
1070
- identities,
1746
+ envelopeDerivation(cached).identityContext,
1747
+ resultFilesystem(cached.result),
1071
1748
  );
1072
- currentHashes[currentKey] = hashText(source);
1073
- if (!sameHashes(cached.inputHashes, currentHashes)) {
1749
+ if (!current.complete) {
1750
+ return false;
1751
+ }
1752
+ current.hashes[currentKey] = hashText(source);
1753
+ if (!sameHashes(cached.inputHashes, current.hashes)) {
1074
1754
  return false;
1075
1755
  }
1076
1756
  // Re-hash the out-of-walk inputs the compiler reported for this generation
@@ -1082,14 +1762,178 @@ function matchesCachedSource(
1082
1762
  // requires a tsconfig or package manifest change, both of which the project
1083
1763
  // walk above already detects.
1084
1764
  const externalHashes = cached.externalInputHashes ?? {};
1085
- return sameHashes(
1086
- externalHashes,
1087
- collectExternalInputHashes(
1088
- cached.externalInputPaths ?? Object.keys(externalHashes),
1089
- ),
1765
+ return (
1766
+ sameHashes(externalHashes, collectCachedExternalInputHashes(cached)) &&
1767
+ matchesExternalInputRealpaths(cached)
1090
1768
  );
1091
1769
  }
1092
1770
 
1771
+ /** Re-check graph-owned physical identities in complete-snapshot fallback. */
1772
+ function matchesExternalInputRealpaths(
1773
+ cached: TtscCachedProjectTransform,
1774
+ ): boolean {
1775
+ const expected = cached.externalInputRealpaths;
1776
+ if (expected === undefined || Object.keys(expected).length === 0) return true;
1777
+ const state = envelopeDerivation(cached);
1778
+ const filesystem = resultFilesystem(cached.result);
1779
+ for (const input of cached.externalInputPaths ?? []) {
1780
+ const identity = derivationIdentity(state, input);
1781
+ if (!Object.prototype.hasOwnProperty.call(expected, identity)) continue;
1782
+ if (
1783
+ !sameHostInputRealpath(
1784
+ expected[identity],
1785
+ hostInputRealpath(input, filesystem),
1786
+ state.identityContext,
1787
+ )
1788
+ ) {
1789
+ return false;
1790
+ }
1791
+ }
1792
+ return true;
1793
+ }
1794
+
1795
+ /**
1796
+ * Capture external-input hashes without attaching post-compile state to an
1797
+ * earlier graph. Graph members must carry compiler-time proof and still match
1798
+ * it now; plugin-declared dependency-only paths retain the historical
1799
+ * post-compile snapshot because their own protocol does not claim generation
1800
+ * fingerprints.
1801
+ */
1802
+ function captureExternalInputSnapshot(
1803
+ cached: TtscCachedProjectTransform,
1804
+ paths: readonly string[],
1805
+ ): {
1806
+ complete: boolean;
1807
+ hashes: Record<string, string>;
1808
+ realpaths: Record<string, string | null>;
1809
+ } {
1810
+ const state = envelopeDerivation(cached);
1811
+ const filesystem = resultFilesystem(cached.result);
1812
+ const graph = envelopeGraphIndexes(state, cached);
1813
+ const hashes: Record<string, string> = {};
1814
+ const realpaths: Record<string, string | null> = {};
1815
+ let complete = true;
1816
+ for (const input of paths) {
1817
+ const identity = derivationIdentity(state, input);
1818
+ if (graph.members.has(identity)) {
1819
+ const proof = graph.inputProofs.get(identity);
1820
+ if (proof === undefined || graph.inputProofConflicts.has(identity)) {
1821
+ complete = false;
1822
+ continue;
1823
+ }
1824
+ const currentHash = graphInputStateHash(input, filesystem);
1825
+ if (
1826
+ currentHash !== proof.hash ||
1827
+ !sameHostInputRealpath(
1828
+ proof.realpath,
1829
+ hostInputRealpath(input, filesystem),
1830
+ state.identityContext,
1831
+ )
1832
+ ) {
1833
+ complete = false;
1834
+ }
1835
+ hashes[identity] = proof.hash ?? "missing";
1836
+ realpaths[identity] = proof.realpath;
1837
+ continue;
1838
+ }
1839
+ hashes[identity] = hostInputStateHash(input, filesystem) ?? "missing";
1840
+ }
1841
+ return { complete, hashes, realpaths };
1842
+ }
1843
+
1844
+ /** Verify every graph member still has the state read by the compiler. */
1845
+ function matchesCompilerGraphInputProofs(
1846
+ cached: TtscCachedProjectTransform,
1847
+ ): boolean {
1848
+ if (
1849
+ cached.result.type === "exception" ||
1850
+ cached.result.graph === undefined ||
1851
+ (cached.result.graph.inputHashes === undefined &&
1852
+ cached.result.graph.inputRealpaths === undefined)
1853
+ ) {
1854
+ // Legacy sidecars remain compatible for ordinary in-project graphs. Their
1855
+ // out-of-walk members are still rejected by captureExternalInputSnapshot,
1856
+ // where a post-compile snapshot cannot prove the compiler's generation.
1857
+ return true;
1858
+ }
1859
+ const state = envelopeDerivation(cached);
1860
+ const filesystem = resultFilesystem(cached.result);
1861
+ const graph = envelopeGraphIndexes(state, cached);
1862
+ if (
1863
+ graph.inputProofConflicts.size !== 0 ||
1864
+ graph.inputProofs.size !== graph.members.size
1865
+ ) {
1866
+ return false;
1867
+ }
1868
+ for (const identity of graph.members) {
1869
+ const proof = graph.inputProofs.get(identity);
1870
+ if (
1871
+ proof === undefined ||
1872
+ graphInputStateHash(proof.path, filesystem) !== proof.hash ||
1873
+ !sameHostInputRealpath(
1874
+ proof.realpath,
1875
+ hostInputRealpath(proof.path, filesystem),
1876
+ state.identityContext,
1877
+ )
1878
+ ) {
1879
+ return false;
1880
+ }
1881
+ }
1882
+ return true;
1883
+ }
1884
+
1885
+ /** Compare one derived input with the snapshot that owned it at generation. */
1886
+ function matchesRecordedInput(
1887
+ cached: TtscCachedProjectTransform,
1888
+ input: string,
1889
+ ): boolean {
1890
+ const state = envelopeDerivation(cached);
1891
+ const filesystem = resultFilesystem(cached.result);
1892
+ const projectKey = toProjectKey(
1893
+ cached.projectRoot,
1894
+ input,
1895
+ state.identityContext,
1896
+ );
1897
+ const projectHash = Object.prototype.hasOwnProperty.call(
1898
+ cached.inputHashes,
1899
+ projectKey,
1900
+ )
1901
+ ? cached.inputHashes[projectKey]
1902
+ : undefined;
1903
+ const identity = derivationIdentity(state, input);
1904
+ const externalHash = (cached.externalInputHashes ?? {})[identity];
1905
+ const externalRealpaths = cached.externalInputRealpaths;
1906
+ const graphInput =
1907
+ externalRealpaths !== undefined &&
1908
+ Object.prototype.hasOwnProperty.call(externalRealpaths, identity);
1909
+ if (
1910
+ externalRealpaths !== undefined &&
1911
+ Object.prototype.hasOwnProperty.call(externalRealpaths, identity) &&
1912
+ !sameHostInputRealpath(
1913
+ externalRealpaths[identity],
1914
+ hostInputRealpath(input, filesystem),
1915
+ state.identityContext,
1916
+ )
1917
+ ) {
1918
+ return false;
1919
+ }
1920
+ // Prefer the out-of-walk spelling's own snapshot when it exists. A lexical
1921
+ // alias can point back into the walked project, where the physical target's
1922
+ // project hash is a different authority (and graph text uses BOM decoding).
1923
+ const recorded = externalHash ?? projectHash;
1924
+ if (recorded === undefined) {
1925
+ return false;
1926
+ }
1927
+ try {
1928
+ const current = graphInput
1929
+ ? graphInputStateHash(input, filesystem)
1930
+ : hostInputStateHash(input, filesystem);
1931
+ return recorded === (current ?? "missing");
1932
+ } catch {
1933
+ return recorded === "missing";
1934
+ }
1935
+ }
1936
+
1093
1937
  /** Record a successfully selected module as delivered by this generation. */
1094
1938
  function markCachedSourceServed(
1095
1939
  cached: TtscCachedProjectTransform,
@@ -1100,32 +1944,6 @@ function markCachedSourceServed(
1100
1944
  );
1101
1945
  }
1102
1946
 
1103
- /**
1104
- * Build the input-hash snapshot stored alongside a fresh compiler result.
1105
- *
1106
- * Hashes every file under the project directory (the exact universe
1107
- * {@link matchesCachedSource} re-hashes to validate), then overlays the
1108
- * in-memory source for the module that triggered the compile so unsaved editor
1109
- * content is captured correctly.
1110
- *
1111
- * Only the project's own files are hashed. Out-of-walk program inputs the
1112
- * compiler also read (`node_modules` declarations, sibling-package sources) are
1113
- * deliberately excluded: the validator never reproduces those keys, so keying
1114
- * them here would make every snapshot comparison fail and the cache never hit.
1115
- */
1116
- function collectInputHashes(props: {
1117
- currentFile: string;
1118
- currentSource: string;
1119
- projectRoot: string;
1120
- }): Record<string, string> {
1121
- const identities = createHostPathIdentityContext();
1122
- const hashes = collectProjectInputHashes(props.projectRoot, identities);
1123
- // Overlay the in-memory source so unsaved edits invalidate the cache.
1124
- hashes[toProjectKey(props.projectRoot, props.currentFile, identities)] =
1125
- hashText(props.currentSource);
1126
- return hashes;
1127
- }
1128
-
1129
1947
  /**
1130
1948
  * Hash every input file under `projectRoot` (the same walk universe
1131
1949
  * {@link matchesCachedSource} validates against), keyed by project-relative
@@ -1135,19 +1953,51 @@ function collectInputHashes(props: {
1135
1953
  export function collectProjectInputHashes(
1136
1954
  projectRoot: string,
1137
1955
  identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
1956
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
1138
1957
  ): Record<string, string> {
1958
+ return collectProjectInputSnapshot(projectRoot, identities, filesystem)
1959
+ .hashes;
1960
+ }
1961
+
1962
+ /** Hash project files and snapshot the directory topology in one walk. */
1963
+ function collectProjectInputSnapshot(
1964
+ projectRoot: string,
1965
+ identities: FilesystemPathIdentityContext,
1966
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
1967
+ ): {
1968
+ complete: boolean;
1969
+ fileSignatures: Record<string, string>;
1970
+ hashes: Record<string, string>;
1971
+ projectDirectories: TtscProjectDirectorySnapshot[];
1972
+ } {
1139
1973
  const hashes: Record<string, string> = {};
1140
- for (const file of listProjectInputFiles(projectRoot)) {
1974
+ const fileSignatures: Record<string, string> = {};
1975
+ const walked = walkProjectInputs(projectRoot, filesystem);
1976
+ let complete = walked.complete;
1977
+ for (const file of walked.files) {
1141
1978
  try {
1142
- hashes[toProjectKey(projectRoot, file, identities)] = hashText(
1143
- fs.readFileSync(file),
1144
- );
1979
+ const before = inputMetadataSignature(file, filesystem);
1980
+ const contents = filesystem.readFile(file);
1981
+ const after = inputMetadataSignature(file, filesystem);
1982
+ const key = toProjectKey(projectRoot, file, identities);
1983
+ hashes[key] = hashText(contents);
1984
+ if (before === undefined || after === undefined || before !== after) {
1985
+ complete = false;
1986
+ } else {
1987
+ fileSignatures[key] = after;
1988
+ }
1145
1989
  } catch {
1146
1990
  // File watchers may observe a transform while another process is moving
1147
1991
  // or deleting files. The missing key invalidates older cache entries.
1992
+ complete = false;
1148
1993
  }
1149
1994
  }
1150
- return hashes;
1995
+ return {
1996
+ complete,
1997
+ fileSignatures,
1998
+ hashes,
1999
+ projectDirectories: walked.directories,
2000
+ };
1151
2001
  }
1152
2002
 
1153
2003
  /**
@@ -1158,17 +2008,46 @@ export function collectProjectInputHashes(
1158
2008
  * unbounded call-stack depth on deep project trees. The result is sorted so
1159
2009
  * that hash comparisons are deterministic across OS-level directory orderings.
1160
2010
  */
1161
- function listProjectInputFiles(root: string): string[] {
1162
- const out: string[] = [];
2011
+ function walkProjectInputs(
2012
+ root: string,
2013
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
2014
+ ): {
2015
+ complete: boolean;
2016
+ directories: TtscProjectDirectorySnapshot[];
2017
+ files: string[];
2018
+ } {
2019
+ let complete = true;
2020
+ const directories: TtscProjectDirectorySnapshot[] = [];
2021
+ const files: string[] = [];
1163
2022
  const stack = [root];
1164
2023
  while (stack.length !== 0) {
1165
2024
  const current = stack.pop()!;
2025
+ const before = projectDirectorySignature(current, filesystem);
2026
+ if (before === undefined) {
2027
+ complete = false;
2028
+ continue;
2029
+ }
1166
2030
  let entries: fs.Dirent[];
1167
2031
  try {
1168
- entries = fs.readdirSync(current, { withFileTypes: true });
2032
+ entries = filesystem.readdir(current);
1169
2033
  } catch {
2034
+ complete = false;
1170
2035
  continue;
1171
2036
  }
2037
+ const after = projectDirectorySignature(current, filesystem);
2038
+ if (after === undefined || before !== after) {
2039
+ complete = false;
2040
+ }
2041
+ directories.push({
2042
+ path: current,
2043
+ // If membership moved during enumeration, force the next delivery to
2044
+ // replace this generation instead of blessing a torn directory/file
2045
+ // snapshot as stable.
2046
+ signature:
2047
+ after !== undefined && before === after
2048
+ ? after
2049
+ : `unstable:${before}:${after ?? "missing"}`,
2050
+ });
1172
2051
  for (const entry of entries) {
1173
2052
  if (isIgnoredProjectDirectory(entry.name)) {
1174
2053
  continue;
@@ -1177,34 +2056,408 @@ function listProjectInputFiles(root: string): string[] {
1177
2056
  if (entry.isDirectory()) {
1178
2057
  stack.push(file);
1179
2058
  } else if (entry.isFile()) {
1180
- out.push(file);
2059
+ files.push(file);
2060
+ }
2061
+ }
2062
+ }
2063
+ directories.sort((left, right) => left.path.localeCompare(right.path));
2064
+ files.sort();
2065
+ return { complete, directories, files };
2066
+ }
2067
+
2068
+ /** Return a cheap identity for one directory's immediate membership. */
2069
+ function projectDirectorySignature(
2070
+ directory: string,
2071
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
2072
+ ): string | undefined {
2073
+ try {
2074
+ const stats = filesystem.statBigInt(directory);
2075
+ if (!stats.isDirectory()) {
2076
+ return undefined;
2077
+ }
2078
+ return [
2079
+ stats.dev,
2080
+ stats.ino,
2081
+ stats.mode,
2082
+ stats.size,
2083
+ stats.mtimeNs,
2084
+ stats.ctimeNs,
2085
+ ].join(":");
2086
+ } catch {
2087
+ return undefined;
2088
+ }
2089
+ }
2090
+
2091
+ /** Compare two deterministic project-directory membership snapshots. */
2092
+ function sameProjectDirectories(
2093
+ left: readonly TtscProjectDirectorySnapshot[],
2094
+ right: readonly TtscProjectDirectorySnapshot[],
2095
+ ): boolean {
2096
+ return (
2097
+ left.length === right.length &&
2098
+ left.every(
2099
+ (directory, index) =>
2100
+ directory.path === right[index]?.path &&
2101
+ directory.signature === right[index]?.signature,
2102
+ )
2103
+ );
2104
+ }
2105
+
2106
+ /** Watch every walked directory for membership changes after generation. */
2107
+ async function createProjectMutationTracker(
2108
+ directories: readonly TtscProjectDirectorySnapshot[],
2109
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
2110
+ ): Promise<TtscProjectMutationTracker> {
2111
+ const tracker: TtscProjectMutationTracker = {
2112
+ close: () => undefined,
2113
+ failed: false,
2114
+ membershipChanged: false,
2115
+ };
2116
+ if (process.platform === "win32") {
2117
+ await registerWindowsProjectMutationTracker(
2118
+ tracker,
2119
+ directories.map((directory) => ({ directory: directory.path })),
2120
+ false,
2121
+ filesystem,
2122
+ );
2123
+ return tracker;
2124
+ }
2125
+ const watchers: fs.FSWatcher[] = [];
2126
+ tracker.close = () => {
2127
+ for (const watcher of watchers) watcher.close();
2128
+ watchers.length = 0;
2129
+ };
2130
+ for (const directory of directories) {
2131
+ try {
2132
+ const watcher = fs.watch(
2133
+ directory.path,
2134
+ { persistent: false },
2135
+ (eventType) => {
2136
+ if (eventType === "rename") tracker.membershipChanged = true;
2137
+ },
2138
+ );
2139
+ watcher.on("error", () => {
2140
+ tracker.failed = true;
2141
+ });
2142
+ watchers.push(watcher);
2143
+ } catch {
2144
+ tracker.failed = true;
2145
+ }
2146
+ }
2147
+ return tracker;
2148
+ }
2149
+
2150
+ /** Watch exact universal inputs, or their nearest existing parent if missing. */
2151
+ async function createHostInputMutationTracker(
2152
+ inputs: readonly string[],
2153
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
2154
+ ): Promise<TtscProjectMutationTracker> {
2155
+ const identities = createHostPathIdentityContext(filesystem);
2156
+ const namesByDirectory = new Map<
2157
+ string,
2158
+ { directory: string; names: Set<string> }
2159
+ >();
2160
+ for (const input of inputs) {
2161
+ const absolute = path.resolve(input);
2162
+ const probe = filesystem.exists(absolute)
2163
+ ? { directory: path.dirname(absolute), name: path.basename(absolute) }
2164
+ : missingPathProbe(absolute, filesystem);
2165
+ const directoryIdentity = identities.resolve(probe.directory);
2166
+ let location = namesByDirectory.get(directoryIdentity.key);
2167
+ if (location === undefined) {
2168
+ location = {
2169
+ directory: directoryIdentity.path,
2170
+ names: new Set<string>(),
2171
+ };
2172
+ namesByDirectory.set(directoryIdentity.key, location);
2173
+ }
2174
+ location.names.add(
2175
+ normalizeHostInputName(
2176
+ probe.name,
2177
+ identities.caseSensitive(directoryIdentity.path),
2178
+ ),
2179
+ );
2180
+ }
2181
+ const locations = [...namesByDirectory.values()].map((location) => ({
2182
+ directory: location.directory,
2183
+ names: [...location.names],
2184
+ }));
2185
+ const tracker: TtscProjectMutationTracker = {
2186
+ close: () => undefined,
2187
+ failed: false,
2188
+ membershipChanged: false,
2189
+ };
2190
+ if (process.platform === "win32") {
2191
+ await registerWindowsProjectMutationTracker(
2192
+ tracker,
2193
+ locations,
2194
+ true,
2195
+ filesystem,
2196
+ );
2197
+ return tracker;
2198
+ }
2199
+ const watchers: fs.FSWatcher[] = [];
2200
+ tracker.close = () => {
2201
+ for (const watcher of watchers) watcher.close();
2202
+ watchers.length = 0;
2203
+ };
2204
+ for (const location of locations) {
2205
+ try {
2206
+ const names = new Set(location.names);
2207
+ const caseSensitive = identities.caseSensitive(location.directory);
2208
+ const watcher = fs.watch(
2209
+ location.directory,
2210
+ { persistent: false },
2211
+ (_eventType, filename) => {
2212
+ const reported =
2213
+ filename === null
2214
+ ? null
2215
+ : normalizeHostInputName(String(filename), caseSensitive);
2216
+ if (reported === null || names.has(reported)) {
2217
+ tracker.membershipChanged = true;
2218
+ }
2219
+ },
2220
+ );
2221
+ watcher.on("error", () => {
2222
+ tracker.failed = true;
2223
+ });
2224
+ watchers.push(watcher);
2225
+ } catch {
2226
+ tracker.failed = true;
2227
+ }
2228
+ }
2229
+ return tracker;
2230
+ }
2231
+
2232
+ interface WindowsProjectMutationBroker {
2233
+ child: ChildProcess;
2234
+ nextId: number;
2235
+ pendingRegistrations: number;
2236
+ trackers: Map<
2237
+ number,
2238
+ {
2239
+ ready: () => void;
2240
+ tracker: TtscProjectMutationTracker;
2241
+ }
2242
+ >;
2243
+ }
2244
+
2245
+ let windowsProjectMutationBroker: WindowsProjectMutationBroker | undefined;
2246
+
2247
+ interface WindowsMutationLocation {
2248
+ directory: string;
2249
+ names?: string[];
2250
+ }
2251
+
2252
+ /**
2253
+ * Register directory watches in an isolated Windows process.
2254
+ *
2255
+ * Node's Windows fs-event backend can assert in native code when a watched
2256
+ * temporary tree is deleted. Isolation turns that unrecoverable process abort
2257
+ * into an ordinary broker exit and a conservative cache miss in the host.
2258
+ */
2259
+ async function registerWindowsProjectMutationTracker(
2260
+ tracker: TtscProjectMutationTracker,
2261
+ locations: readonly WindowsMutationLocation[],
2262
+ allEvents: boolean,
2263
+ filesystem: TtscTransformFilesystemOperations,
2264
+ ): Promise<void> {
2265
+ const broker = getWindowsProjectMutationBroker();
2266
+ const normalized = locations.map((location) => {
2267
+ let directory: string;
2268
+ try {
2269
+ directory = filesystem.realpath(location.directory);
2270
+ } catch {
2271
+ directory = path.resolve(location.directory);
2272
+ }
2273
+ return {
2274
+ directory,
2275
+ ...(location.names === undefined ? {} : { names: location.names }),
2276
+ };
2277
+ });
2278
+ broker.pendingRegistrations += 1;
2279
+ broker.child.ref();
2280
+ broker.child.channel?.ref();
2281
+ const id = broker.nextId++;
2282
+ let resolveReady!: () => void;
2283
+ const ready = new Promise<void>((resolve) => {
2284
+ resolveReady = resolve;
2285
+ });
2286
+ broker.trackers.set(id, { ready: resolveReady, tracker });
2287
+ tracker.close = () => {
2288
+ const active = broker.trackers.get(id);
2289
+ if (active === undefined) return;
2290
+ broker.trackers.delete(id);
2291
+ active.ready();
2292
+ broker.child.send?.({ id, op: "remove" });
2293
+ if (broker.trackers.size === 0) {
2294
+ broker.child.disconnect?.();
2295
+ broker.child.kill();
2296
+ if (windowsProjectMutationBroker === broker) {
2297
+ windowsProjectMutationBroker = undefined;
1181
2298
  }
1182
2299
  }
2300
+ };
2301
+ broker.child.send?.({
2302
+ allEvents,
2303
+ locations: normalized,
2304
+ id,
2305
+ op: "add",
2306
+ });
2307
+ try {
2308
+ await ready;
2309
+ } finally {
2310
+ broker.pendingRegistrations -= 1;
2311
+ if (broker.pendingRegistrations === 0) {
2312
+ broker.child.unref();
2313
+ broker.child.channel?.unref();
2314
+ }
2315
+ }
2316
+ }
2317
+
2318
+ function getWindowsProjectMutationBroker(): WindowsProjectMutationBroker {
2319
+ if (windowsProjectMutationBroker !== undefined) {
2320
+ return windowsProjectMutationBroker;
1183
2321
  }
1184
- out.sort();
1185
- return out;
2322
+ const child = spawn(process.execPath, ["-e", WINDOWS_WATCH_BROKER_SOURCE], {
2323
+ stdio: ["ignore", "ignore", "ignore", "ipc"],
2324
+ windowsHide: true,
2325
+ });
2326
+ const broker: WindowsProjectMutationBroker = {
2327
+ child,
2328
+ nextId: 1,
2329
+ pendingRegistrations: 0,
2330
+ trackers: new Map(),
2331
+ };
2332
+ const fail = (): void => {
2333
+ for (const registration of broker.trackers.values()) {
2334
+ registration.tracker.failed = true;
2335
+ registration.ready();
2336
+ }
2337
+ broker.trackers.clear();
2338
+ if (windowsProjectMutationBroker === broker) {
2339
+ windowsProjectMutationBroker = undefined;
2340
+ }
2341
+ };
2342
+ child.on("error", fail);
2343
+ child.on("exit", fail);
2344
+ child.on("message", (message: unknown) => {
2345
+ if (message === null || typeof message !== "object") return;
2346
+ const record = message as {
2347
+ failed?: boolean;
2348
+ id?: number;
2349
+ ready?: boolean;
2350
+ };
2351
+ if (typeof record.id !== "number") return;
2352
+ const registration = broker.trackers.get(record.id);
2353
+ if (registration === undefined) return;
2354
+ if (record.failed === true) registration.tracker.failed = true;
2355
+ if (record.ready === true) registration.ready();
2356
+ if (record.ready !== true && record.failed !== true) {
2357
+ registration.tracker.membershipChanged = true;
2358
+ }
2359
+ });
2360
+ windowsProjectMutationBroker = broker;
2361
+ return broker;
2362
+ }
2363
+
2364
+ const WINDOWS_WATCH_BROKER_SOURCE = [
2365
+ 'const fs = require("node:fs");',
2366
+ "const groups = new Map();",
2367
+ 'process.on("message", (message) => {',
2368
+ ' if (message.op === "remove") {',
2369
+ " close(message.id);",
2370
+ " return;",
2371
+ " }",
2372
+ ' if (message.op !== "add") return;',
2373
+ " const watchers = [];",
2374
+ " let failed = false;",
2375
+ " for (const location of message.locations) {",
2376
+ " try {",
2377
+ " const names = location.names === undefined ? undefined : new Set(location.names.map((name) => name.toLowerCase()));",
2378
+ " const watcher = fs.watch(location.directory, { persistent: false }, (event, filename) => {",
2379
+ " const matches = names === undefined || filename === null || names.has(String(filename).toLowerCase());",
2380
+ ' if (matches && (message.allEvents || event === "rename")) process.send?.({ id: message.id });',
2381
+ " });",
2382
+ ' watcher.on("error", () => process.send?.({ failed: true, id: message.id }));',
2383
+ " watchers.push(watcher);",
2384
+ " } catch {",
2385
+ " failed = true;",
2386
+ " }",
2387
+ " }",
2388
+ " groups.set(message.id, watchers);",
2389
+ " process.send?.({ failed, id: message.id, ready: true });",
2390
+ "});",
2391
+ 'process.on("disconnect", () => {',
2392
+ " for (const id of groups.keys()) close(id);",
2393
+ " process.exit(0);",
2394
+ "});",
2395
+ "function close(id) {",
2396
+ " for (const watcher of groups.get(id) ?? []) watcher.close();",
2397
+ " groups.delete(id);",
2398
+ "}",
2399
+ ].join("\n");
2400
+
2401
+ /** Report whether live directory notifications preserve project membership. */
2402
+ function matchesProjectMembership(cached: TtscCachedProjectTransform): boolean {
2403
+ const tracker = cached.projectMutationTracker;
2404
+ return (
2405
+ tracker !== undefined &&
2406
+ tracker.failed === false &&
2407
+ tracker.membershipChanged === false
2408
+ );
2409
+ }
2410
+
2411
+ /**
2412
+ * Yield once before persistent validation so synchronous edits can reach the
2413
+ * directory watchers that guard membership. Concurrent sibling deliveries share
2414
+ * the same barrier.
2415
+ */
2416
+ async function settleProjectMutationEvents(
2417
+ cached: TtscCachedProjectTransform,
2418
+ ): Promise<void> {
2419
+ const trackers = [
2420
+ cached.projectMutationTracker,
2421
+ cached.hostInputMutationTracker,
2422
+ ].filter(
2423
+ (tracker): tracker is TtscProjectMutationTracker => tracker !== undefined,
2424
+ );
2425
+ await Promise.all(
2426
+ trackers.map(async (tracker) => {
2427
+ tracker.settle ??= new Promise<void>((resolve) => {
2428
+ const settled = () => {
2429
+ tracker.settle = undefined;
2430
+ resolve();
2431
+ };
2432
+ if (process.platform === "win32") setTimeout(settled, 10);
2433
+ else setImmediate(settled);
2434
+ });
2435
+ await tracker.settle;
2436
+ }),
2437
+ );
1186
2438
  }
1187
2439
 
1188
2440
  /**
1189
2441
  * Report whether an absolute `file` belongs to the project walk universe of
1190
2442
  * `root`: it lies under `root`, every component exists without traversing a
1191
2443
  * symbolic link, the leaf is a regular file, and no segment of the relative
1192
- * path is ignored. The predicate mirrors {@link listProjectInputFiles} exactly,
1193
- * so "walk-visible" here means "hashed by {@link collectProjectInputHashes}".
2444
+ * path is ignored. The predicate mirrors {@link walkProjectInputs} exactly, so
2445
+ * "walk-visible" here means "hashed by {@link collectProjectInputHashes}".
1194
2446
  * Missing paths and files reached through symlinks or Windows junctions are
1195
2447
  * out-of-walk inputs that only the reference graph can prove relevant.
1196
2448
  */
1197
2449
  export function isProjectWalkPath(
1198
2450
  root: string,
1199
2451
  file: string,
1200
- identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
2452
+ _identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
2453
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
1201
2454
  ): boolean {
1202
- if (!identities.isWithin(root, file)) {
1203
- return false;
1204
- }
1205
- const rootKey = pathIdentityKey(root, identities);
1206
- const fileKey = pathIdentityKey(file, identities);
1207
- const relative = fileKey.slice(rootKey.length).replace(/^[/\\]+/, "");
2455
+ // Walk membership is lexical. Resolving `file` to physical identity first
2456
+ // would turn `root/alias/value.ts` into `root/target/value.ts`, hide the
2457
+ // symlink segment from the lstat loop below, and falsely claim the project
2458
+ // walk hashed a path it deliberately never followed.
2459
+ const resolvedRoot = path.resolve(root);
2460
+ const relative = path.relative(resolvedRoot, path.resolve(file));
1208
2461
  if (
1209
2462
  relative.length === 0 ||
1210
2463
  relative === ".." ||
@@ -1217,12 +2470,12 @@ export function isProjectWalkPath(
1217
2470
  if (segments.some(isIgnoredProjectDirectory)) {
1218
2471
  return false;
1219
2472
  }
1220
- let current = path.resolve(root);
2473
+ let current = resolvedRoot;
1221
2474
  for (let index = 0; index < segments.length; ++index) {
1222
2475
  current = path.join(current, segments[index]!);
1223
- let stats: fs.Stats;
2476
+ let stats: fs.BigIntStats;
1224
2477
  try {
1225
- stats = fs.lstatSync(current);
2478
+ stats = filesystem.lstat(current);
1226
2479
  } catch {
1227
2480
  return false;
1228
2481
  }
@@ -1239,28 +2492,46 @@ export function isProjectWalkPath(
1239
2492
 
1240
2493
  /**
1241
2494
  * Hash a list of absolute out-of-walk input paths: content SHA-256 for a
1242
- * readable file, a stable `missing` marker otherwise. Keys use filesystem
1243
- * identity so case-only spellings share one snapshot entry, while reads retain
1244
- * the original path supplied by the compiler. The marker is state, not an error
1245
- * a recorded input disappearing (or reappearing) must change the comparison
1246
- * exactly like a content edit. Exported so `@ttsc/metro` can re-hash its
1247
- * recorded snapshot with identical semantics at cache-key time.
2495
+ * readable file, a stable directory-kind digest for a directory candidate, and
2496
+ * a stable `missing` marker otherwise. Keys use filesystem identity so
2497
+ * case-only spellings share one snapshot entry, while reads retain the original
2498
+ * path supplied by the compiler. The marker is state, not an error — a recorded
2499
+ * input disappearing (or reappearing) must change the comparison exactly like a
2500
+ * content edit. Exported so `@ttsc/metro` can re-hash its recorded snapshot
2501
+ * with identical semantics at cache-key time.
1248
2502
  */
1249
2503
  export function collectExternalInputHashes(
1250
2504
  paths: readonly string[],
2505
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
1251
2506
  ): Record<string, string> {
1252
2507
  const hashes: Record<string, string> = {};
1253
- const identities = createHostPathIdentityContext();
2508
+ const identities = createHostPathIdentityContext(filesystem);
1254
2509
  for (const file of paths) {
1255
2510
  const identity = pathIdentityKey(file, identities);
1256
2511
  if (identity in hashes) {
1257
2512
  continue;
1258
2513
  }
1259
- try {
1260
- hashes[identity] = hashText(fs.readFileSync(file));
1261
- } catch {
1262
- hashes[identity] = "missing";
1263
- }
2514
+ hashes[identity] = hostInputStateHash(file, filesystem) ?? "missing";
2515
+ }
2516
+ return hashes;
2517
+ }
2518
+
2519
+ /** Re-hash a cached mixed graph/dependency input set with its owning codec. */
2520
+ function collectCachedExternalInputHashes(
2521
+ cached: TtscCachedProjectTransform,
2522
+ ): Record<string, string> {
2523
+ const hashes: Record<string, string> = {};
2524
+ const state = envelopeDerivation(cached);
2525
+ const graphRealpaths = cached.externalInputRealpaths ?? {};
2526
+ const filesystem = resultFilesystem(cached.result);
2527
+ for (const file of cached.externalInputPaths ??
2528
+ Object.keys(cached.externalInputHashes ?? {})) {
2529
+ const identity = derivationIdentity(state, file);
2530
+ if (identity in hashes) continue;
2531
+ hashes[identity] =
2532
+ (Object.prototype.hasOwnProperty.call(graphRealpaths, identity)
2533
+ ? graphInputStateHash(file, filesystem)
2534
+ : hostInputStateHash(file, filesystem)) ?? "missing";
1264
2535
  }
1265
2536
  return hashes;
1266
2537
  }
@@ -1274,16 +2545,14 @@ export function collectExternalInputHashes(
1274
2545
  * that are still missing remain in this set even under the project root: the
1275
2546
  * first walk cannot hash a file that has not been created yet.
1276
2547
  *
1277
- * A `dependenciesComplete` declaration deliberately does not narrow this set,
1278
- * unlike the per-file watch derivation. This cache replays one whole envelope,
1279
- * so its validity condition is the union over every file the envelope carries
1280
- * rather than one file's inputs; a miss here costs a re-transform, never a
1281
- * stale output; and it is the layer that re-runs the plugin's analysis, which
1282
- * is how a widened declaration is ever learned. The narrowing that matters
1283
- * lands at the bundler boundary through {@link selectWatchInputs}, which is what
1284
- * feeds persistent caches and watch graphs.
2548
+ * A `dependenciesComplete` declaration deliberately does not narrow the stored
2549
+ * set: other files in the same whole-project result can still own the omitted
2550
+ * members. Persistent validation selects the requested file's subset through
2551
+ * {@link selectWatchInputs}, while graph-free envelopes use this union as their
2552
+ * conservative fallback.
1285
2553
  */
1286
2554
  function selectExternalInputPaths(props: {
2555
+ filesystem?: TtscTransformFilesystemOperations;
1287
2556
  projectRoot: string;
1288
2557
  result: ITtscCompilerTransformation;
1289
2558
  temporaryTsconfig?: string;
@@ -1292,7 +2561,8 @@ function selectExternalInputPaths(props: {
1292
2561
  return [];
1293
2562
  }
1294
2563
  const members: string[] = [];
1295
- const identities = createHostPathIdentityContext();
2564
+ const filesystem = props.filesystem ?? DEFAULT_FILESYSTEM_OPERATIONS;
2565
+ const identities = createHostPathIdentityContext(filesystem);
1296
2566
  const resolutionCandidates = new Set<string>();
1297
2567
  const graph = props.result.graph;
1298
2568
  if (graph !== undefined) {
@@ -1326,6 +2596,19 @@ function selectExternalInputPaths(props: {
1326
2596
  members.push(...entries);
1327
2597
  }
1328
2598
  }
2599
+ if (Array.isArray(props.result.hostInputs)) {
2600
+ for (const input of props.result.hostInputs) {
2601
+ members.push(input);
2602
+ if (typeof input === "string" && input.length !== 0) {
2603
+ // Plugin discovery inputs deliberately include absent config and
2604
+ // resolution probes. A project walk cannot snapshot a path that does
2605
+ // not exist yet, even when its spelling lies below projectRoot.
2606
+ resolutionCandidates.add(
2607
+ pathIdentityKey(path.resolve(props.projectRoot, input), identities),
2608
+ );
2609
+ }
2610
+ }
2611
+ }
1329
2612
  const excluded =
1330
2613
  props.temporaryTsconfig === undefined
1331
2614
  ? undefined
@@ -1337,18 +2620,21 @@ function selectExternalInputPaths(props: {
1337
2620
  continue;
1338
2621
  }
1339
2622
  const absolute = path.resolve(props.projectRoot, member);
2623
+ const spelling = path.resolve(absolute);
1340
2624
  const identity = pathIdentityKey(absolute, identities);
1341
2625
  const missingCandidate =
1342
- resolutionCandidates.has(identity) && !fs.existsSync(absolute);
2626
+ resolutionCandidates.has(identity) && !filesystem.exists(absolute);
1343
2627
  if (
1344
2628
  identity === excluded ||
1345
- seen.has(identity) ||
2629
+ seen.has(spelling) ||
1346
2630
  (!missingCandidate &&
1347
- isProjectWalkPath(props.projectRoot, absolute, identities))
2631
+ isProjectWalkPath(props.projectRoot, absolute, identities, filesystem))
1348
2632
  ) {
1349
2633
  continue;
1350
2634
  }
1351
- seen.add(identity);
2635
+ // Preserve distinct lexical aliases even when they currently select the
2636
+ // same physical file. A later retarget must validate the alias itself.
2637
+ seen.add(spelling);
1352
2638
  output.push(absolute);
1353
2639
  }
1354
2640
  output.sort();
@@ -1396,43 +2682,103 @@ async function transformProject(props: {
1396
2682
  compilerOptions: Record<string, unknown>;
1397
2683
  currentFile: string;
1398
2684
  currentSource: string;
2685
+ filesystem: TtscTransformFilesystemOperations;
1399
2686
  plugins?: ResolvedTtscUnpluginOptions["plugins"];
2687
+ trackProjectMembership: boolean;
1400
2688
  tsconfig: string;
1401
2689
  }): Promise<TtscCachedProjectTransform> {
1402
- const configured = createTransformTsconfig(props);
1403
2690
  const projectRoot = path.dirname(props.tsconfig);
2691
+ const scratchDirectory = createTransformScratchDirectory(
2692
+ projectRoot,
2693
+ props.filesystem,
2694
+ );
2695
+ let tracker: TtscProjectMutationTracker | undefined;
2696
+ let retainTracker = false;
2697
+ let hostInputTracker: TtscProjectMutationTracker | undefined;
1404
2698
  try {
1405
- const result = new TtscCompiler({
1406
- cwd: projectRoot,
1407
- // The generated tsconfig (if any) lives in the system temp directory,
1408
- // so declare the real project as the plugin config anchor: utility
1409
- // plugin config discovery (banner.config.*, strip.config.*,
1410
- // lint.config.*) and relative configFile resolution walk the project,
1411
- // never the temp tree. In the passthrough case this equals the
1412
- // tsconfig's own directory, the default anchor.
1413
- pluginConfigDir: projectRoot,
1414
- plugins: props.plugins,
1415
- projectRoot,
1416
- tsconfig: configured.path,
1417
- }).transform();
2699
+ const configured = createTransformTsconfig(props, scratchDirectory);
1418
2700
  const temporaryTsconfig =
1419
2701
  configured.path === props.tsconfig ? undefined : configured.path;
2702
+ const identities = createHostPathIdentityContext(props.filesystem);
2703
+ const before = collectProjectInputSnapshot(
2704
+ projectRoot,
2705
+ identities,
2706
+ props.filesystem,
2707
+ );
2708
+ tracker = props.trackProjectMembership
2709
+ ? await createProjectMutationTracker(
2710
+ before.projectDirectories,
2711
+ props.filesystem,
2712
+ )
2713
+ : undefined;
2714
+ const result = withTransformScratchEnvironment(scratchDirectory, () =>
2715
+ new TtscCompiler({
2716
+ cwd: projectRoot,
2717
+ // The generated tsconfig (if any) lives outside the project directory,
2718
+ // so declare the real project as the plugin config anchor: utility
2719
+ // plugin config discovery (banner.config.*, strip.config.*,
2720
+ // lint.config.*) and relative configFile resolution walk the project,
2721
+ // never the temp tree. In the passthrough case this equals the
2722
+ // tsconfig's own directory, the default anchor.
2723
+ pluginConfigDir: projectRoot,
2724
+ plugins: props.plugins,
2725
+ projectRoot,
2726
+ tsconfig: configured.path,
2727
+ env: transformScratchEnvironment(scratchDirectory),
2728
+ }).transform(),
2729
+ );
2730
+ TRANSFORM_RESULT_FILESYSTEM.set(result, props.filesystem);
2731
+ const persistentHostInputs = selectPersistentHostInputs({
2732
+ filesystem: props.filesystem,
2733
+ projectRoot,
2734
+ result,
2735
+ temporaryTsconfig,
2736
+ });
2737
+ hostInputTracker = props.trackProjectMembership
2738
+ ? await createHostInputMutationTracker(
2739
+ persistentHostInputs,
2740
+ props.filesystem,
2741
+ )
2742
+ : undefined;
1420
2743
  const externalInputPaths = selectExternalInputPaths({
2744
+ filesystem: props.filesystem,
1421
2745
  projectRoot,
1422
2746
  result,
1423
2747
  temporaryTsconfig,
1424
2748
  });
1425
- return {
2749
+ const inputSnapshot = collectProjectInputSnapshot(
2750
+ projectRoot,
2751
+ identities,
2752
+ props.filesystem,
2753
+ );
2754
+ let stableProjectSnapshot =
2755
+ before.complete &&
2756
+ inputSnapshot.complete &&
2757
+ sameHashes(before.hashes, inputSnapshot.hashes) &&
2758
+ sameHashes(before.fileSignatures, inputSnapshot.fileSignatures) &&
2759
+ sameProjectDirectories(
2760
+ before.projectDirectories,
2761
+ inputSnapshot.projectDirectories,
2762
+ ) &&
2763
+ tracker?.failed !== true &&
2764
+ tracker?.membershipChanged !== true &&
2765
+ hostInputTracker?.failed !== true &&
2766
+ hostInputTracker?.membershipChanged !== true;
2767
+ // Overlay the in-memory source only after proving the two on-disk snapshots
2768
+ // stable; an unsaved editor buffer must not look like a compile-time race.
2769
+ inputSnapshot.hashes[
2770
+ toProjectKey(projectRoot, props.currentFile, identities)
2771
+ ] = hashText(props.currentSource);
2772
+ const cached: TtscCachedProjectTransform = {
1426
2773
  // Capture the out-of-walk input hashes while the generation is fresh so
1427
2774
  // cache validation can re-check them; computed before dispose so the
1428
2775
  // exclusion of the temp-dir tsconfig is the only reason it never keys.
1429
- externalInputHashes: collectExternalInputHashes(externalInputPaths),
2776
+ externalInputHashes: {},
2777
+ externalInputRealpaths: {},
1430
2778
  externalInputPaths,
1431
- inputHashes: collectInputHashes({
1432
- currentFile: props.currentFile,
1433
- currentSource: props.currentSource,
1434
- projectRoot,
1435
- }),
2779
+ inputHashes: inputSnapshot.hashes,
2780
+ projectDirectories: inputSnapshot.projectDirectories,
2781
+ projectSnapshotComplete: false,
1436
2782
  projectRoot,
1437
2783
  result,
1438
2784
  servedFiles: new Set(),
@@ -1441,16 +2787,72 @@ async function transformProject(props: {
1441
2787
  // but deleted file would invalidate every persistent-cache snapshot.
1442
2788
  ...(temporaryTsconfig === undefined ? {} : { temporaryTsconfig }),
1443
2789
  };
2790
+ const externalInputSnapshot = captureExternalInputSnapshot(
2791
+ cached,
2792
+ externalInputPaths,
2793
+ );
2794
+ cached.externalInputHashes = externalInputSnapshot.hashes;
2795
+ cached.externalInputRealpaths = externalInputSnapshot.realpaths;
2796
+ stableProjectSnapshot =
2797
+ stableProjectSnapshot &&
2798
+ matchesCompilerGraphInputProofs(cached) &&
2799
+ externalInputSnapshot.complete &&
2800
+ captureUniversalHostInputValidation(cached, props.currentFile) !==
2801
+ undefined;
2802
+ cached.projectSnapshotComplete = stableProjectSnapshot;
2803
+ if (stableProjectSnapshot && tracker !== undefined) {
2804
+ cached.projectMutationTracker = tracker;
2805
+ }
2806
+ if (stableProjectSnapshot && hostInputTracker !== undefined) {
2807
+ cached.hostInputMutationTracker = hostInputTracker;
2808
+ }
2809
+ retainTracker =
2810
+ stableProjectSnapshot &&
2811
+ tracker !== undefined &&
2812
+ hostInputTracker !== undefined;
2813
+ return cached;
1444
2814
  } finally {
1445
- configured.dispose();
2815
+ try {
2816
+ if (!retainTracker && tracker !== undefined) {
2817
+ tracker.close();
2818
+ }
2819
+ } finally {
2820
+ try {
2821
+ if (!retainTracker && hostInputTracker !== undefined) {
2822
+ hostInputTracker.close();
2823
+ }
2824
+ } finally {
2825
+ fs.rmSync(scratchDirectory, { force: true, recursive: true });
2826
+ }
2827
+ }
1446
2828
  }
1447
2829
  }
1448
2830
 
1449
- function createTransformTsconfig(props: {
1450
- aliasPaths: Record<string, string[]>;
1451
- compilerOptions: Record<string, unknown>;
1452
- tsconfig: string;
1453
- }): { path: string; dispose: () => void } {
2831
+ /** Exclude the disposed overlay tsconfig from live host-input tracking. */
2832
+ function selectPersistentHostInputs(props: {
2833
+ filesystem: TtscTransformFilesystemOperations;
2834
+ projectRoot: string;
2835
+ result: ITtscCompilerTransformation;
2836
+ temporaryTsconfig?: string;
2837
+ }): string[] {
2838
+ if (props.result.type === "exception") return [];
2839
+ const inputs = selectListedFiles(props.projectRoot, props.result.hostInputs);
2840
+ if (props.temporaryTsconfig === undefined) return inputs;
2841
+ const identities = createHostPathIdentityContext(props.filesystem);
2842
+ const temporary = pathIdentityKey(props.temporaryTsconfig, identities);
2843
+ return inputs.filter(
2844
+ (input) => pathIdentityKey(input, identities) !== temporary,
2845
+ );
2846
+ }
2847
+
2848
+ function createTransformTsconfig(
2849
+ props: {
2850
+ aliasPaths: Record<string, string[]>;
2851
+ compilerOptions: Record<string, unknown>;
2852
+ tsconfig: string;
2853
+ },
2854
+ scratchDirectory: string,
2855
+ ): { path: string } {
1454
2856
  const compilerOptions = normalizeCompilerOptionsForGeneratedTsconfig(
1455
2857
  {
1456
2858
  ...props.compilerOptions,
@@ -1459,14 +2861,10 @@ function createTransformTsconfig(props: {
1459
2861
  path.dirname(props.tsconfig),
1460
2862
  );
1461
2863
  if (Object.keys(compilerOptions).length === 0) {
1462
- return {
1463
- path: props.tsconfig,
1464
- dispose: () => undefined,
1465
- };
2864
+ return { path: props.tsconfig };
1466
2865
  }
1467
2866
 
1468
- const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ttsc-unplugin-"));
1469
- const file = path.join(directory, "tsconfig.json");
2867
+ const file = path.join(scratchDirectory, "tsconfig.json");
1470
2868
  fs.writeFileSync(
1471
2869
  file,
1472
2870
  JSON.stringify(
@@ -1479,19 +2877,134 @@ function createTransformTsconfig(props: {
1479
2877
  ),
1480
2878
  "utf8",
1481
2879
  );
2880
+ return { path: file };
2881
+ }
2882
+
2883
+ /** Create compiler scratch storage outside the project snapshot and watchers. */
2884
+ function createTransformScratchDirectory(
2885
+ projectRoot: string,
2886
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
2887
+ ): string {
2888
+ const root = path.resolve(projectRoot);
2889
+ const canonicalRoot = filesystem.realpath(root);
2890
+ const platformTemp =
2891
+ process.platform === "win32" && process.env.LOCALAPPDATA
2892
+ ? path.join(process.env.LOCALAPPDATA, "Temp")
2893
+ : "/tmp";
2894
+ const candidates = [
2895
+ os.tmpdir(),
2896
+ platformTemp,
2897
+ path.dirname(root),
2898
+ os.homedir(),
2899
+ ];
2900
+ const canonicalCandidates = new Set<string>();
2901
+ let failure: unknown;
2902
+ for (const candidate of new Set(candidates.map((dir) => path.resolve(dir)))) {
2903
+ if (pathIsWithin(candidate, root)) continue;
2904
+ let canonicalCandidate: string;
2905
+ try {
2906
+ canonicalCandidate = filesystem.realpath(candidate);
2907
+ } catch (error) {
2908
+ failure = error;
2909
+ continue;
2910
+ }
2911
+ if (
2912
+ pathIsWithin(canonicalCandidate, canonicalRoot) ||
2913
+ canonicalCandidates.has(canonicalCandidate)
2914
+ ) {
2915
+ continue;
2916
+ }
2917
+ canonicalCandidates.add(canonicalCandidate);
2918
+ let directory: string;
2919
+ try {
2920
+ directory = fs.mkdtempSync(
2921
+ path.join(canonicalCandidate, "ttsc-unplugin-"),
2922
+ );
2923
+ } catch (error) {
2924
+ failure = error;
2925
+ continue;
2926
+ }
2927
+ let canonicalDirectory: string;
2928
+ try {
2929
+ canonicalDirectory = filesystem.realpath(directory);
2930
+ } catch (error) {
2931
+ try {
2932
+ fs.rmdirSync(directory);
2933
+ } catch (cleanupError) {
2934
+ throw cleanupError;
2935
+ }
2936
+ failure = error;
2937
+ continue;
2938
+ }
2939
+ // Use the postflight canonical spelling from this point onward. Returning
2940
+ // the candidate-relative spelling would let another process retarget its
2941
+ // parent symlink/junction after validation, redirecting compiler writes or
2942
+ // the final recursive removal into the project.
2943
+ if (!pathIsWithin(canonicalDirectory, canonicalRoot)) {
2944
+ return canonicalDirectory;
2945
+ }
2946
+ // Refuse the result and synchronously remove only our empty random child
2947
+ // through the identity that the postflight check just classified.
2948
+ fs.rmdirSync(canonicalDirectory);
2949
+ }
2950
+ throw (
2951
+ failure ??
2952
+ new Error("ttsc: no temporary directory exists outside the project")
2953
+ );
2954
+ }
2955
+
2956
+ function pathIsWithin(child: string, parent: string): boolean {
2957
+ const relative = path.relative(parent, child);
2958
+ return (
2959
+ relative === "" ||
2960
+ (relative !== ".." &&
2961
+ !relative.startsWith(`..${path.sep}`) &&
2962
+ !path.isAbsolute(relative))
2963
+ );
2964
+ }
2965
+
2966
+ /** Route all compiler/plugin scratch to one owned directory outside project. */
2967
+ function transformScratchEnvironment(directory: string): NodeJS.ProcessEnv {
1482
2968
  return {
1483
- path: file,
1484
- dispose: () => fs.rmSync(directory, { force: true, recursive: true }),
2969
+ ...process.env,
2970
+ TEMP: directory,
2971
+ TMP: directory,
2972
+ TMPDIR: directory,
1485
2973
  };
1486
2974
  }
1487
2975
 
2976
+ /** Scope parent-process temp consumers to the same owned scratch directory. */
2977
+ function withTransformScratchEnvironment<T>(
2978
+ scratchDirectory: string,
2979
+ callback: () => T,
2980
+ ): T {
2981
+ const environment = transformScratchEnvironment(scratchDirectory);
2982
+ const previous = {
2983
+ TEMP: process.env.TEMP,
2984
+ TMP: process.env.TMP,
2985
+ TMPDIR: process.env.TMPDIR,
2986
+ };
2987
+ process.env.TEMP = environment.TEMP;
2988
+ process.env.TMP = environment.TMP;
2989
+ process.env.TMPDIR = environment.TMPDIR;
2990
+ try {
2991
+ return callback();
2992
+ } finally {
2993
+ for (const [name, value] of Object.entries(previous)) {
2994
+ if (value === undefined) delete process.env[name];
2995
+ else process.env[name] = value;
2996
+ }
2997
+ }
2998
+ }
2999
+
1488
3000
  /**
1489
3001
  * Resolve all relative paths inside `compilerOptions` against `tsconfigDir`.
1490
3002
  *
1491
- * The generated tsconfig lives in a system temp directory, so any relative path
1492
- * (e.g. `"outDir": "../dist"`) that was meaningful relative to the original
1493
- * tsconfig must be converted to an absolute path before writing the generated
1494
- * file. Otherwise TypeScript-Go resolves it against the temp dir.
3003
+ * The generated tsconfig lives in a temporary directory outside the project, so
3004
+ * any relative path (e.g. `"outDir": "../dist"`) that was meaningful relative
3005
+ * to the original tsconfig must be converted to an absolute path before writing
3006
+ * the generated file. Otherwise TypeScript-Go resolves it against the temp
3007
+ * dir.
1495
3008
  *
1496
3009
  * `paths` targets are absolutized for the same reason, with the extra twist
1497
3010
  * that TypeScript-Go rejects bare non-relative targets outright (TS5090) and
@@ -1826,7 +3339,11 @@ function formatUnknownError(error: unknown): string {
1826
3339
  * compiler will error if that file does not exist, which is the correct
1827
3340
  * behavior for a mis-configured project.
1828
3341
  */
1829
- function resolveTsconfig(file: string, tsconfig?: string): string {
3342
+ function resolveTsconfig(
3343
+ file: string,
3344
+ tsconfig?: string,
3345
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
3346
+ ): string {
1830
3347
  if (tsconfig !== undefined) {
1831
3348
  return path.isAbsolute(tsconfig)
1832
3349
  ? tsconfig
@@ -1836,7 +3353,7 @@ function resolveTsconfig(file: string, tsconfig?: string): string {
1836
3353
  let current = path.dirname(file);
1837
3354
  while (true) {
1838
3355
  const candidate = path.join(current, "tsconfig.json");
1839
- if (fs.existsSync(candidate)) {
3356
+ if (filesystem.exists(candidate)) {
1840
3357
  return candidate;
1841
3358
  }
1842
3359
  const parent = path.dirname(current);