@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,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ var node_child_process = require('node:child_process');
3
4
  var crypto = require('node:crypto');
4
5
  var fs = require('node:fs');
5
6
  var os = require('node:os');
@@ -8,19 +9,58 @@ var ttsc = require('ttsc');
8
9
  var pathIdentity = require('ttsc/path-identity');
9
10
  var tsconfigPaths = require('./tsconfigPaths.js');
10
11
 
12
+ const DEFAULT_FILESYSTEM_OPERATIONS = Object.freeze({
13
+ exists: fs.existsSync,
14
+ lstat: (location) => fs.lstatSync(location, { bigint: true }),
15
+ readFile: (location) => fs.readFileSync(location),
16
+ readdir: (location) => fs.readdirSync(location, { withFileTypes: true }),
17
+ realpath: fs.realpathSync.native,
18
+ stat: fs.statSync,
19
+ statBigInt: (location) => fs.statSync(location, { bigint: true }),
20
+ });
21
+ const TRANSFORM_CACHE_FILESYSTEM = new WeakMap();
22
+ const TRANSFORM_RESULT_FILESYSTEM = new WeakMap();
11
23
  /**
12
24
  * Caches whose owner has declared a real per-build lifecycle by calling
13
25
  * {@link beginTtscTransformBuild} before transforms begin.
14
26
  */
15
27
  const BUILD_SCOPED_TRANSFORM_CACHES = new WeakSet();
16
- function createHostPathIdentityContext() {
28
+ function createHostPathIdentityContext(filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
17
29
  return pathIdentity.createFilesystemPathIdentityContext({
30
+ caseSensitive: filesystem.caseSensitive,
31
+ lstat: filesystem.lstat,
32
+ platform: filesystem.platform,
33
+ readdir: (directory) => filesystem.readdir(directory).map((entry) => entry.name),
34
+ realpath: filesystem.realpath,
18
35
  throwOnRealpathError: false,
19
36
  });
20
37
  }
21
- /** Create an empty persistent transform cache. */
22
- function createTtscTransformCache() {
23
- return new Map();
38
+ /** Normalize one directory entry under the owning filesystem's case policy. */
39
+ function normalizeHostInputName(name, caseSensitive) {
40
+ return caseSensitive ? name : name.toLowerCase();
41
+ }
42
+ /** Create an empty persistent transform cache with isolated filesystem reads. */
43
+ function createTtscTransformCache(operations = {}) {
44
+ const cache = new Map();
45
+ TRANSFORM_CACHE_FILESYSTEM.set(cache, {
46
+ caseSensitive: operations.caseSensitive,
47
+ exists: operations.exists ?? DEFAULT_FILESYSTEM_OPERATIONS.exists,
48
+ lstat: operations.lstat ?? DEFAULT_FILESYSTEM_OPERATIONS.lstat,
49
+ readFile: operations.readFile ?? DEFAULT_FILESYSTEM_OPERATIONS.readFile,
50
+ readdir: operations.readdir ?? DEFAULT_FILESYSTEM_OPERATIONS.readdir,
51
+ realpath: operations.realpath ?? DEFAULT_FILESYSTEM_OPERATIONS.realpath,
52
+ stat: operations.stat ?? DEFAULT_FILESYSTEM_OPERATIONS.stat,
53
+ statBigInt: operations.statBigInt ?? DEFAULT_FILESYSTEM_OPERATIONS.statBigInt,
54
+ platform: operations.platform,
55
+ });
56
+ return cache;
57
+ }
58
+ function transformFilesystem(cache) {
59
+ return ((cache === undefined ? undefined : TRANSFORM_CACHE_FILESYSTEM.get(cache)) ??
60
+ DEFAULT_FILESYSTEM_OPERATIONS);
61
+ }
62
+ function resultFilesystem(result) {
63
+ return (TRANSFORM_RESULT_FILESYSTEM.get(result) ?? DEFAULT_FILESYSTEM_OPERATIONS);
24
64
  }
25
65
  /**
26
66
  * Start a host build, clearing its prior generation and enabling constant-time
@@ -31,7 +71,7 @@ function createTtscTransformCache() {
31
71
  * defines one process-scoped module-loading session.
32
72
  */
33
73
  function beginTtscTransformBuild(cache) {
34
- cache.clear();
74
+ clearTtscTransformCache(cache);
35
75
  BUILD_SCOPED_TRANSFORM_CACHES.add(cache);
36
76
  }
37
77
  /**
@@ -42,9 +82,17 @@ function beginTtscTransformBuild(cache) {
42
82
  * many edits, so that callback cannot authorize build-scoped shortcuts.
43
83
  */
44
84
  function resetTtscTransformCache(cache) {
45
- cache.clear();
85
+ clearTtscTransformCache(cache);
46
86
  BUILD_SCOPED_TRANSFORM_CACHES.delete(cache);
47
87
  }
88
+ /** Dispose generation-owned filesystem resources before clearing a cache. */
89
+ function clearTtscTransformCache(cache) {
90
+ const generations = [...cache.values()];
91
+ cache.clear();
92
+ for (const generation of generations) {
93
+ void generation.then(disposeCachedTransform, () => undefined);
94
+ }
95
+ }
48
96
  /**
49
97
  * Apply the ttsc plugin transform to a single source file.
50
98
  *
@@ -70,6 +118,7 @@ function resetTtscTransformCache(cache) {
70
118
  * per build, not per compilation.
71
119
  */
72
120
  async function transformTtsc(id, source, options, aliases, cache, hooks) {
121
+ const filesystem = transformFilesystem(cache);
73
122
  const clean = stripQuery(id);
74
123
  if (clean.includes("\0")) {
75
124
  return undefined;
@@ -81,7 +130,7 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
81
130
  if (pluginsAreDisabled(options.plugins)) {
82
131
  return undefined;
83
132
  }
84
- const tsconfig = resolveTsconfig(file, options.project);
133
+ const tsconfig = resolveTsconfig(file, options.project, filesystem);
85
134
  const aliasPaths = createAliasPaths(aliases);
86
135
  const key = createTransformCacheKey({
87
136
  aliasPaths,
@@ -95,11 +144,19 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
95
144
  // A rejected in-flight generation must not stay cached: evict it (only if
96
145
  // it is still the current entry) so a later call re-runs the transform.
97
146
  const cached = await awaitOrEvict(cache, key, transformed);
147
+ TRANSFORM_RESULT_FILESYSTEM.set(cached.result, filesystem);
98
148
  // While this caller awaited the old Promise, another caller may have
99
149
  // invalidated it and installed a newer authoritative generation.
100
150
  if (cache?.get(key) !== transformed) {
101
151
  continue;
102
152
  }
153
+ const buildScoped = cache !== undefined && BUILD_SCOPED_TRANSFORM_CACHES.has(cache);
154
+ if (!buildScoped) {
155
+ await settleProjectMutationEvents(cached);
156
+ if (cache?.get(key) !== transformed) {
157
+ continue;
158
+ }
159
+ }
103
160
  if (
104
161
  // A file the plugin declared volatile must never be served from the
105
162
  // cache: its output depends on non-file inputs, so the input-hash
@@ -109,7 +166,7 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
109
166
  projectRoot: cached.projectRoot,
110
167
  result: cached.result,
111
168
  }) &&
112
- matchesCachedSource(cached, file, source, cache !== undefined && BUILD_SCOPED_TRANSFORM_CACHES.has(cache))) {
169
+ matchesCachedSource(cached, file, source, buildScoped)) {
113
170
  reportSuccessDiagnostics(cached.result);
114
171
  // A resolved `"exception"` / `"failure"` envelope makes this throw;
115
172
  // that is a failed generation too, so evict before surfacing it.
@@ -142,7 +199,9 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
142
199
  compilerOptions: options.compilerOptions,
143
200
  currentFile: file,
144
201
  currentSource: source,
202
+ filesystem,
145
203
  plugins: options.plugins,
204
+ trackProjectMembership: cache !== undefined,
146
205
  tsconfig,
147
206
  });
148
207
  cache?.set(key, transformed);
@@ -209,8 +268,20 @@ function selectOrEvict(cache, key, generation, props) {
209
268
  function evictGeneration(cache, key, generation) {
210
269
  if (cache?.get(key) === generation) {
211
270
  cache.delete(key);
271
+ void generation.then(disposeCachedTransform, () => undefined);
212
272
  }
213
273
  }
274
+ /** Close one generation's directory watchers exactly once. */
275
+ function disposeCachedTransform(cached) {
276
+ const trackers = [
277
+ cached.projectMutationTracker,
278
+ cached.hostInputMutationTracker,
279
+ ];
280
+ cached.projectMutationTracker = undefined;
281
+ cached.hostInputMutationTracker = undefined;
282
+ for (const tracker of trackers)
283
+ tracker?.close();
284
+ }
214
285
  /**
215
286
  * Derivation states keyed by the compiler result object. One result object is
216
287
  * produced by one compile against one project root, so the root captured at
@@ -224,7 +295,7 @@ function envelopeDerivation(props) {
224
295
  return existing;
225
296
  }
226
297
  const created = {
227
- identityContext: createHostPathIdentityContext(),
298
+ identityContext: createHostPathIdentityContext(resultFilesystem(props.result)),
228
299
  identities: new Map(),
229
300
  watchInputs: new Map(),
230
301
  };
@@ -246,6 +317,9 @@ function envelopeGraphIndexes(state, props) {
246
317
  candidates: [],
247
318
  globals: [],
248
319
  configs: [],
320
+ members: new Set(),
321
+ inputProofs: new Map(),
322
+ inputProofConflicts: new Set(),
249
323
  };
250
324
  const graph = props.result.type === "exception" ? undefined : props.result.graph;
251
325
  if (graph !== undefined) {
@@ -255,23 +329,73 @@ function envelopeGraphIndexes(state, props) {
255
329
  }
256
330
  const absolute = path.resolve(props.projectRoot, source);
257
331
  const identity = derivationIdentity(state, absolute);
332
+ built.members.add(identity);
258
333
  built.spellings.set(identity, absolute);
259
334
  const entries = built.edges.get(identity) ?? [];
260
335
  entries.push(...targets
261
336
  .filter((target) => typeof target === "string" && target.length !== 0)
262
- .map((target) => path.resolve(props.projectRoot, target)));
337
+ .map((target) => {
338
+ const absoluteTarget = path.resolve(props.projectRoot, target);
339
+ built.members.add(derivationIdentity(state, absoluteTarget));
340
+ return absoluteTarget;
341
+ }));
263
342
  built.edges.set(identity, entries);
264
343
  }
265
344
  built.globals.push(...selectListedFiles(props.projectRoot, graph.globals));
266
345
  built.configs.push(...selectListedFiles(props.projectRoot, graph.configs));
346
+ for (const input of [...built.globals, ...built.configs]) {
347
+ built.members.add(derivationIdentity(state, input));
348
+ }
267
349
  for (const [source, candidates] of Object.entries(graph.candidates ?? {})) {
268
350
  if (!Array.isArray(candidates)) {
269
351
  continue;
270
352
  }
353
+ const sourceIdentity = derivationIdentity(state, path.resolve(props.projectRoot, source));
354
+ built.members.add(sourceIdentity);
271
355
  built.candidates.push({
272
- source: derivationIdentity(state, path.resolve(props.projectRoot, source)),
356
+ source: sourceIdentity,
273
357
  files: selectListedFiles(props.projectRoot, candidates),
274
358
  });
359
+ for (const candidate of candidates) {
360
+ if (typeof candidate !== "string" || candidate.length === 0)
361
+ continue;
362
+ built.members.add(derivationIdentity(state, path.resolve(props.projectRoot, candidate)));
363
+ }
364
+ }
365
+ for (const [input, hash] of Object.entries(graph.inputHashes ?? {})) {
366
+ if (hash !== null &&
367
+ (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash))) {
368
+ continue;
369
+ }
370
+ if (graph.inputRealpaths === undefined ||
371
+ !Object.prototype.hasOwnProperty.call(graph.inputRealpaths, input)) {
372
+ continue;
373
+ }
374
+ const reportedRealpath = graph.inputRealpaths[input];
375
+ if (reportedRealpath !== null &&
376
+ (typeof reportedRealpath !== "string" ||
377
+ !path.isAbsolute(reportedRealpath))) {
378
+ continue;
379
+ }
380
+ const absolute = path.resolve(props.projectRoot, input);
381
+ const identity = derivationIdentity(state, absolute);
382
+ if (!built.members.has(identity))
383
+ continue;
384
+ const proof = {
385
+ hash,
386
+ path: absolute,
387
+ realpath: reportedRealpath === null ? null : path.resolve(reportedRealpath),
388
+ };
389
+ const previous = built.inputProofs.get(identity);
390
+ if (previous !== undefined &&
391
+ (previous.hash !== proof.hash ||
392
+ !sameHostInputRealpath(previous.realpath, proof.realpath, state.identityContext))) {
393
+ built.inputProofs.delete(identity);
394
+ built.inputProofConflicts.add(identity);
395
+ }
396
+ else if (!built.inputProofConflicts.has(identity)) {
397
+ built.inputProofs.set(identity, proof);
398
+ }
275
399
  }
276
400
  }
277
401
  state.graph = built;
@@ -372,29 +496,59 @@ function selectWatchInputs(props) {
372
496
  function deriveWatchInputs(state, props, fileIdentity) {
373
497
  const graph = envelopeGraphIndexes(state, props);
374
498
  const output = [];
375
- const seen = new Set();
499
+ const physicalSeen = new Set();
500
+ const lexicalSeen = new Set();
376
501
  const excluded = new Set([fileIdentity]);
377
502
  if (props.temporaryTsconfig !== undefined) {
378
503
  excluded.add(derivationIdentity(state, props.temporaryTsconfig));
379
504
  }
380
- for (const absolute of [
381
- ...selectFileDependencies(props),
382
- ...selectGraphInputs(graph, state, {
383
- ...props,
384
- complete: declaresCompleteDependencies(state, props) &&
385
- !isVolatileFile(state, props),
386
- }),
387
- ...selectResolutionCandidateInputs(graph, state, props),
388
- ]) {
389
- const identity = derivationIdentity(state, absolute);
390
- if (excluded.has(identity) || seen.has(identity)) {
391
- continue;
505
+ const currentSpelling = path.resolve(props.file);
506
+ const temporarySpelling = props.temporaryTsconfig === undefined
507
+ ? undefined
508
+ : path.resolve(props.temporaryTsconfig);
509
+ const appendLexical = (input) => {
510
+ const spelling = path.resolve(input);
511
+ if (spelling === currentSpelling ||
512
+ spelling === temporarySpelling ||
513
+ lexicalSeen.has(spelling)) {
514
+ return;
392
515
  }
393
- seen.add(identity);
394
- output.push(absolute);
395
- }
516
+ lexicalSeen.add(spelling);
517
+ physicalSeen.add(derivationIdentity(state, input));
518
+ output.push(input);
519
+ };
520
+ const appendPhysical = (input) => {
521
+ const identity = derivationIdentity(state, input);
522
+ if (excluded.has(identity) || physicalSeen.has(identity))
523
+ return;
524
+ physicalSeen.add(identity);
525
+ lexicalSeen.add(path.resolve(input));
526
+ output.push(input);
527
+ };
528
+ for (const input of selectFileDependencies(props))
529
+ appendLexical(input);
530
+ for (const input of selectGraphInputs(graph, state, {
531
+ ...props,
532
+ complete: declaresCompleteDependencies(state, props) &&
533
+ !isVolatileFile(state, props),
534
+ }))
535
+ appendPhysical(input);
536
+ // Resolution candidates, plugin dependencies, and universal host inputs
537
+ // preserve lexical aliases. Physical deduplication would collapse
538
+ // `alias/selection.cjs` into the selected target path, so a bundler would
539
+ // watch only the target and miss a symlink/junction retarget.
540
+ for (const input of selectResolutionCandidateInputs(graph, state, props))
541
+ appendLexical(input);
542
+ for (const input of selectHostInputs(props))
543
+ appendLexical(input);
396
544
  return output;
397
545
  }
546
+ /** Return exact host-wide descriptor/config inputs for every output file. */
547
+ function selectHostInputs(props) {
548
+ return props.result.type === "exception"
549
+ ? []
550
+ : selectListedFiles(props.projectRoot, props.result.hostInputs);
551
+ }
398
552
  /**
399
553
  * Return the module-resolution paths that can supersede a currently resolved
400
554
  * module reachable from `file`. They remain host-owned even when a plugin
@@ -640,14 +794,13 @@ function createTransformResult(source, code) {
640
794
  *
641
795
  * Always compares the current module's in-memory source with the generation
642
796
  * snapshot. A cache whose owner called {@link beginTtscTransformBuild} can use
643
- * that comparison alone for the module's first delivery in the current build;
644
- * repeated requests re-hash every project and out-of-walk input. Persistent
645
- * caches with no guaranteed build boundary perform complete validation on every
646
- * hit. Any mismatch forces a complete re-transform.
647
- *
648
- * The complete validation snapshot and {@link collectInputHashes} draw their
649
- * keys from the exact same {@link collectProjectInputHashes} walk, so the two
650
- * agree on the key universe.
797
+ * that comparison alone for a stable generation's first module delivery in the
798
+ * current build. An incomplete generation may not take this shortcut: otherwise
799
+ * a sibling output captured during a filesystem race could still be served
800
+ * once. Later graph-bearing requests validate the file's derived input set and
801
+ * project membership; graph-free envelopes conservatively re-hash the complete
802
+ * project and out-of-walk snapshots. Any mismatch forces a complete
803
+ * re-transform.
651
804
  */
652
805
  function matchesCachedSource(cached, file, source, buildScoped) {
653
806
  const identities = envelopeDerivation(cached).identityContext;
@@ -656,12 +809,346 @@ function matchesCachedSource(cached, file, source, buildScoped) {
656
809
  return false;
657
810
  }
658
811
  if (buildScoped &&
812
+ cached.projectSnapshotComplete === true &&
659
813
  !cached.servedFiles?.has(pathIdentityKey(file, identities))) {
660
814
  return true;
661
815
  }
662
- const currentHashes = collectProjectInputHashes(cached.projectRoot, identities);
663
- currentHashes[currentKey] = hashText(source);
664
- if (!sameHashes(cached.inputHashes, currentHashes)) {
816
+ if (cached.result.type !== "exception" &&
817
+ cached.result.graph !== undefined &&
818
+ cached.projectSnapshotComplete === true &&
819
+ cached.projectDirectories !== undefined &&
820
+ cached.projectMutationTracker !== undefined &&
821
+ cached.hostInputMutationTracker !== undefined) {
822
+ return matchesNarrowPersistentInputs(cached, file);
823
+ }
824
+ return matchesCompleteInputSnapshot(cached, currentKey, source);
825
+ }
826
+ /**
827
+ * Validate one graph-bearing cached output against only the inputs that can
828
+ * affect that file. Project membership is validated once per event-loop turn,
829
+ * so sibling module deliveries share one directory-metadata pass instead of
830
+ * multiplying it by module count.
831
+ */
832
+ function matchesNarrowPersistentInputs(cached, file) {
833
+ if (!matchesProjectMembership(cached)) {
834
+ return false;
835
+ }
836
+ const hostTracker = cached.hostInputMutationTracker;
837
+ if (hostTracker === undefined ||
838
+ hostTracker.failed ||
839
+ hostTracker.membershipChanged) {
840
+ return false;
841
+ }
842
+ const state = envelopeDerivation(cached);
843
+ const hostValidation = state.hostInputValidation;
844
+ if (hostValidation === undefined ||
845
+ !matchesUniversalHostInputs(cached, hostValidation)) {
846
+ return false;
847
+ }
848
+ const inputs = selectWatchInputs({
849
+ file,
850
+ projectRoot: cached.projectRoot,
851
+ result: cached.result,
852
+ temporaryTsconfig: cached.temporaryTsconfig,
853
+ });
854
+ for (const input of inputs) {
855
+ if (hostValidation.identities.has(derivationIdentity(state, input))) {
856
+ continue;
857
+ }
858
+ if (!matchesRecordedInput(cached, input)) {
859
+ return false;
860
+ }
861
+ }
862
+ return true;
863
+ }
864
+ /**
865
+ * Validate universal descriptor/config inputs without re-reading them for every
866
+ * module. Existing paths use the same nanosecond metadata manifest that guards
867
+ * GOROOT identity memoization; missing probes are grouped by the nearest
868
+ * existing directory and checked through one exact membership listing.
869
+ */
870
+ function matchesUniversalHostInputs(cached, validation) {
871
+ const filesystem = resultFilesystem(cached.result);
872
+ for (const entry of validation.entries.values()) {
873
+ const signature = inputMetadataSignature(entry.path, filesystem);
874
+ if (signature === entry.signature)
875
+ continue;
876
+ if (entry.strict === true)
877
+ return false;
878
+ if (hostInputRealpath(entry.path, filesystem) !== entry.realpath)
879
+ return false;
880
+ if (!matchesRecordedInput(cached, entry.path)) {
881
+ return false;
882
+ }
883
+ if (signature === undefined)
884
+ return false;
885
+ entry.signature = signature;
886
+ }
887
+ for (const [directory, names] of validation.missing) {
888
+ let entries;
889
+ try {
890
+ entries = filesystem.readdir(directory);
891
+ }
892
+ catch (error) {
893
+ // Only a provably absent/non-directory ancestor keeps every descendant
894
+ // unreachable. Permission and transient I/O failures cannot prove that
895
+ // a candidate is still missing, while replacing the proving directory
896
+ // with an exact file can itself redirect module resolution.
897
+ try {
898
+ if (!filesystem.stat(directory).isDirectory())
899
+ return false;
900
+ }
901
+ catch (statError) {
902
+ if (!isMissingPathError(statError))
903
+ return false;
904
+ continue;
905
+ }
906
+ return false;
907
+ }
908
+ const identities = envelopeDerivation(cached).identityContext;
909
+ const caseSensitive = identities.caseSensitive(directory);
910
+ if (entries.some((entry) => names.has(normalizeHostInputName(entry.name, caseSensitive)))) {
911
+ return false;
912
+ }
913
+ }
914
+ return true;
915
+ }
916
+ /** True only for errors that prove a path cannot currently be traversed. */
917
+ function isMissingPathError(error) {
918
+ const code = error?.code;
919
+ return code === "ENOENT" || code === "ENOTDIR";
920
+ }
921
+ /** Capture the universal-input manifest while the generation is still fresh. */
922
+ function captureUniversalHostInputValidation(cached, currentFile) {
923
+ const filesystem = resultFilesystem(cached.result);
924
+ const state = envelopeDerivation(cached);
925
+ const validation = {
926
+ entries: new Map(),
927
+ identities: new Set(),
928
+ missing: new Map(),
929
+ };
930
+ for (const input of selectPersistentHostInputs({
931
+ filesystem,
932
+ projectRoot: cached.projectRoot,
933
+ result: cached.result,
934
+ temporaryTsconfig: cached.temporaryTsconfig,
935
+ })) {
936
+ const generationHashes = cached.result.type === "exception"
937
+ ? undefined
938
+ : cached.result.hostInputHashes;
939
+ const generationRealpaths = cached.result.type === "exception"
940
+ ? undefined
941
+ : cached.result.hostInputRealpaths;
942
+ const expected = generationHashes?.[path.resolve(input)];
943
+ // Every persistent universal input must carry an evaluation-time
944
+ // fingerprint. If a plugin/native host cannot provide one, keep the fresh
945
+ // result but decline narrow long-lived reuse.
946
+ if (expected === undefined) {
947
+ const current = path.resolve(currentFile);
948
+ if (path.resolve(input) !== current)
949
+ return undefined;
950
+ // The current module may be supplied from an unsaved editor buffer. Its
951
+ // generation snapshot is overlaid below from `currentSource`, so a disk
952
+ // fingerprint would be both unavailable and the wrong authority.
953
+ }
954
+ else if (expected !== hostInputStateHash(input, filesystem)) {
955
+ return undefined;
956
+ }
957
+ const absoluteInput = path.resolve(input);
958
+ if (generationRealpaths !== undefined) {
959
+ if (!Object.prototype.hasOwnProperty.call(generationRealpaths, absoluteInput) ||
960
+ !sameHostInputRealpath(generationRealpaths[absoluteInput], hostInputRealpath(input, filesystem), state.identityContext)) {
961
+ return undefined;
962
+ }
963
+ }
964
+ const identity = derivationIdentity(state, input);
965
+ validation.identities.add(identity);
966
+ const before = inputMetadataSignature(input, filesystem);
967
+ if (!matchesRecordedInput(cached, input))
968
+ return undefined;
969
+ const after = inputMetadataSignature(input, filesystem);
970
+ if (before !== after)
971
+ return undefined;
972
+ if (after !== undefined) {
973
+ // Do not key this manifest by physical identity. A symlink/junction
974
+ // spelling and its selected target deliberately share that identity,
975
+ // but both lexical paths must survive so retargeting the alias is visible.
976
+ validation.entries.set(path.resolve(input), {
977
+ path: input,
978
+ realpath: hostInputRealpath(input, filesystem),
979
+ signature: after,
980
+ });
981
+ continue;
982
+ }
983
+ const probe = missingPathProbe(input, filesystem);
984
+ if (probe.blocker !== undefined) {
985
+ const blockerIdentity = derivationIdentity(state, probe.blocker);
986
+ const signature = inputMetadataSignature(probe.blocker, filesystem);
987
+ if (signature === undefined)
988
+ return undefined;
989
+ validation.identities.add(blockerIdentity);
990
+ validation.entries.set(path.resolve(probe.blocker), {
991
+ path: probe.blocker,
992
+ realpath: hostInputRealpath(probe.blocker, filesystem),
993
+ signature,
994
+ strict: true,
995
+ });
996
+ continue;
997
+ }
998
+ let names = validation.missing.get(probe.directory);
999
+ if (names === undefined) {
1000
+ names = new Set();
1001
+ validation.missing.set(probe.directory, names);
1002
+ }
1003
+ names.add(normalizeHostInputName(probe.name, state.identityContext.caseSensitive(probe.directory)));
1004
+ }
1005
+ state.hostInputValidation = validation;
1006
+ return validation;
1007
+ }
1008
+ /** Metadata identity whose stability lets a generation reuse a content hash. */
1009
+ function inputMetadataSignature(file, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1010
+ try {
1011
+ const link = filesystem.lstat(file);
1012
+ let target = link;
1013
+ if (link.isSymbolicLink()) {
1014
+ try {
1015
+ target = filesystem.statBigInt(file);
1016
+ }
1017
+ catch {
1018
+ // Keep a broken link in the existing-input manifest. Its own metadata
1019
+ // stays stable while the target is missing, and the first successful
1020
+ // stat after the target appears changes this signature. Treating it as
1021
+ // a plain missing path would watch/list only the link's parent, which
1022
+ // cannot observe a target created in another directory.
1023
+ return [
1024
+ link.dev,
1025
+ link.ino,
1026
+ link.mode,
1027
+ link.size,
1028
+ link.mtimeNs,
1029
+ link.ctimeNs,
1030
+ "missing-target",
1031
+ ].join(":");
1032
+ }
1033
+ }
1034
+ return [
1035
+ link.dev,
1036
+ link.ino,
1037
+ link.mode,
1038
+ link.size,
1039
+ link.mtimeNs,
1040
+ link.ctimeNs,
1041
+ target.dev,
1042
+ target.ino,
1043
+ target.mode,
1044
+ target.size,
1045
+ target.mtimeNs,
1046
+ target.ctimeNs,
1047
+ ].join(":");
1048
+ }
1049
+ catch {
1050
+ return undefined;
1051
+ }
1052
+ }
1053
+ /** Content/kind fingerprint matching the compiler host-input contract. */
1054
+ function hostInputStateHash(file, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1055
+ try {
1056
+ return hashText(filesystem.readFile(file));
1057
+ }
1058
+ catch {
1059
+ try {
1060
+ return filesystem.stat(file).isDirectory()
1061
+ ? hashText("ttsc:host-input:directory\0")
1062
+ : null;
1063
+ }
1064
+ catch {
1065
+ return null;
1066
+ }
1067
+ }
1068
+ }
1069
+ /** Fingerprint the text/kind state returned by TypeScript-Go's filesystem. */
1070
+ function graphInputStateHash(file, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1071
+ try {
1072
+ const bytes = filesystem.readFile(file);
1073
+ if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
1074
+ const even = bytes.subarray(2, 2 + Math.floor((bytes.length - 2) / 2) * 2);
1075
+ return hashText(Buffer.from(even.toString("utf16le"), "utf8"));
1076
+ }
1077
+ if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
1078
+ const even = Buffer.from(bytes.subarray(2, 2 + Math.floor((bytes.length - 2) / 2) * 2));
1079
+ even.swap16();
1080
+ return hashText(Buffer.from(even.toString("utf16le"), "utf8"));
1081
+ }
1082
+ const content = bytes.length >= 3 &&
1083
+ bytes[0] === 0xef &&
1084
+ bytes[1] === 0xbb &&
1085
+ bytes[2] === 0xbf
1086
+ ? bytes.subarray(3)
1087
+ : bytes;
1088
+ return hashText(content);
1089
+ }
1090
+ catch {
1091
+ try {
1092
+ return filesystem.stat(file).isDirectory()
1093
+ ? hashText("ttsc:host-input:directory\0")
1094
+ : null;
1095
+ }
1096
+ catch {
1097
+ return null;
1098
+ }
1099
+ }
1100
+ }
1101
+ /** Physical target selected by a lexical host-input path. */
1102
+ function hostInputRealpath(file, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1103
+ try {
1104
+ return filesystem.realpath(file);
1105
+ }
1106
+ catch {
1107
+ return null;
1108
+ }
1109
+ }
1110
+ /** Compare two reported realpaths by filesystem identity, not Windows spelling. */
1111
+ function sameHostInputRealpath(left, right, identities) {
1112
+ if (left === undefined || (left === null) !== (right === null))
1113
+ return false;
1114
+ if (left === null || right === null)
1115
+ return true;
1116
+ return (pathIdentityKey(left, identities) === pathIdentityKey(right, identities));
1117
+ }
1118
+ /** Find one directory listing that proves an absent path is still absent. */
1119
+ function missingPathProbe(file, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1120
+ let child = path.resolve(file);
1121
+ for (;;) {
1122
+ const directory = path.dirname(child);
1123
+ try {
1124
+ const stats = filesystem.stat(directory);
1125
+ if (stats.isDirectory()) {
1126
+ return { directory, name: path.basename(child) };
1127
+ }
1128
+ return {
1129
+ blocker: directory,
1130
+ directory: path.dirname(directory),
1131
+ name: path.basename(directory),
1132
+ };
1133
+ }
1134
+ catch { }
1135
+ if (directory === child) {
1136
+ return { directory, name: path.basename(child) };
1137
+ }
1138
+ child = directory;
1139
+ }
1140
+ }
1141
+ /** Fall back to the historical whole-envelope validation without a graph. */
1142
+ function matchesCompleteInputSnapshot(cached, currentKey, source) {
1143
+ if (cached.projectSnapshotComplete !== true) {
1144
+ return false;
1145
+ }
1146
+ const current = collectProjectInputSnapshot(cached.projectRoot, envelopeDerivation(cached).identityContext, resultFilesystem(cached.result));
1147
+ if (!current.complete) {
1148
+ return false;
1149
+ }
1150
+ current.hashes[currentKey] = hashText(source);
1151
+ if (!sameHashes(cached.inputHashes, current.hashes)) {
665
1152
  return false;
666
1153
  }
667
1154
  // Re-hash the out-of-walk inputs the compiler reported for this generation
@@ -673,32 +1160,127 @@ function matchesCachedSource(cached, file, source, buildScoped) {
673
1160
  // requires a tsconfig or package manifest change, both of which the project
674
1161
  // walk above already detects.
675
1162
  const externalHashes = cached.externalInputHashes ?? {};
676
- return sameHashes(externalHashes, collectExternalInputHashes(cached.externalInputPaths ?? Object.keys(externalHashes)));
1163
+ return (sameHashes(externalHashes, collectCachedExternalInputHashes(cached)) &&
1164
+ matchesExternalInputRealpaths(cached));
677
1165
  }
678
- /** Record a successfully selected module as delivered by this generation. */
679
- function markCachedSourceServed(cached, file) {
680
- (cached.servedFiles ??= new Set()).add(pathIdentityKey(file, envelopeDerivation(cached).identityContext));
1166
+ /** Re-check graph-owned physical identities in complete-snapshot fallback. */
1167
+ function matchesExternalInputRealpaths(cached) {
1168
+ const expected = cached.externalInputRealpaths;
1169
+ if (expected === undefined || Object.keys(expected).length === 0)
1170
+ return true;
1171
+ const state = envelopeDerivation(cached);
1172
+ const filesystem = resultFilesystem(cached.result);
1173
+ for (const input of cached.externalInputPaths ?? []) {
1174
+ const identity = derivationIdentity(state, input);
1175
+ if (!Object.prototype.hasOwnProperty.call(expected, identity))
1176
+ continue;
1177
+ if (!sameHostInputRealpath(expected[identity], hostInputRealpath(input, filesystem), state.identityContext)) {
1178
+ return false;
1179
+ }
1180
+ }
1181
+ return true;
681
1182
  }
682
1183
  /**
683
- * Build the input-hash snapshot stored alongside a fresh compiler result.
684
- *
685
- * Hashes every file under the project directory (the exact universe
686
- * {@link matchesCachedSource} re-hashes to validate), then overlays the
687
- * in-memory source for the module that triggered the compile so unsaved editor
688
- * content is captured correctly.
689
- *
690
- * Only the project's own files are hashed. Out-of-walk program inputs the
691
- * compiler also read (`node_modules` declarations, sibling-package sources) are
692
- * deliberately excluded: the validator never reproduces those keys, so keying
693
- * them here would make every snapshot comparison fail and the cache never hit.
1184
+ * Capture external-input hashes without attaching post-compile state to an
1185
+ * earlier graph. Graph members must carry compiler-time proof and still match
1186
+ * it now; plugin-declared dependency-only paths retain the historical
1187
+ * post-compile snapshot because their own protocol does not claim generation
1188
+ * fingerprints.
694
1189
  */
695
- function collectInputHashes(props) {
696
- const identities = createHostPathIdentityContext();
697
- const hashes = collectProjectInputHashes(props.projectRoot, identities);
698
- // Overlay the in-memory source so unsaved edits invalidate the cache.
699
- hashes[toProjectKey(props.projectRoot, props.currentFile, identities)] =
700
- hashText(props.currentSource);
701
- return hashes;
1190
+ function captureExternalInputSnapshot(cached, paths) {
1191
+ const state = envelopeDerivation(cached);
1192
+ const filesystem = resultFilesystem(cached.result);
1193
+ const graph = envelopeGraphIndexes(state, cached);
1194
+ const hashes = {};
1195
+ const realpaths = {};
1196
+ let complete = true;
1197
+ for (const input of paths) {
1198
+ const identity = derivationIdentity(state, input);
1199
+ if (graph.members.has(identity)) {
1200
+ const proof = graph.inputProofs.get(identity);
1201
+ if (proof === undefined || graph.inputProofConflicts.has(identity)) {
1202
+ complete = false;
1203
+ continue;
1204
+ }
1205
+ const currentHash = graphInputStateHash(input, filesystem);
1206
+ if (currentHash !== proof.hash ||
1207
+ !sameHostInputRealpath(proof.realpath, hostInputRealpath(input, filesystem), state.identityContext)) {
1208
+ complete = false;
1209
+ }
1210
+ hashes[identity] = proof.hash ?? "missing";
1211
+ realpaths[identity] = proof.realpath;
1212
+ continue;
1213
+ }
1214
+ hashes[identity] = hostInputStateHash(input, filesystem) ?? "missing";
1215
+ }
1216
+ return { complete, hashes, realpaths };
1217
+ }
1218
+ /** Verify every graph member still has the state read by the compiler. */
1219
+ function matchesCompilerGraphInputProofs(cached) {
1220
+ if (cached.result.type === "exception" ||
1221
+ cached.result.graph === undefined ||
1222
+ (cached.result.graph.inputHashes === undefined &&
1223
+ cached.result.graph.inputRealpaths === undefined)) {
1224
+ // Legacy sidecars remain compatible for ordinary in-project graphs. Their
1225
+ // out-of-walk members are still rejected by captureExternalInputSnapshot,
1226
+ // where a post-compile snapshot cannot prove the compiler's generation.
1227
+ return true;
1228
+ }
1229
+ const state = envelopeDerivation(cached);
1230
+ const filesystem = resultFilesystem(cached.result);
1231
+ const graph = envelopeGraphIndexes(state, cached);
1232
+ if (graph.inputProofConflicts.size !== 0 ||
1233
+ graph.inputProofs.size !== graph.members.size) {
1234
+ return false;
1235
+ }
1236
+ for (const identity of graph.members) {
1237
+ const proof = graph.inputProofs.get(identity);
1238
+ if (proof === undefined ||
1239
+ graphInputStateHash(proof.path, filesystem) !== proof.hash ||
1240
+ !sameHostInputRealpath(proof.realpath, hostInputRealpath(proof.path, filesystem), state.identityContext)) {
1241
+ return false;
1242
+ }
1243
+ }
1244
+ return true;
1245
+ }
1246
+ /** Compare one derived input with the snapshot that owned it at generation. */
1247
+ function matchesRecordedInput(cached, input) {
1248
+ const state = envelopeDerivation(cached);
1249
+ const filesystem = resultFilesystem(cached.result);
1250
+ const projectKey = toProjectKey(cached.projectRoot, input, state.identityContext);
1251
+ const projectHash = Object.prototype.hasOwnProperty.call(cached.inputHashes, projectKey)
1252
+ ? cached.inputHashes[projectKey]
1253
+ : undefined;
1254
+ const identity = derivationIdentity(state, input);
1255
+ const externalHash = (cached.externalInputHashes ?? {})[identity];
1256
+ const externalRealpaths = cached.externalInputRealpaths;
1257
+ const graphInput = externalRealpaths !== undefined &&
1258
+ Object.prototype.hasOwnProperty.call(externalRealpaths, identity);
1259
+ if (externalRealpaths !== undefined &&
1260
+ Object.prototype.hasOwnProperty.call(externalRealpaths, identity) &&
1261
+ !sameHostInputRealpath(externalRealpaths[identity], hostInputRealpath(input, filesystem), state.identityContext)) {
1262
+ return false;
1263
+ }
1264
+ // Prefer the out-of-walk spelling's own snapshot when it exists. A lexical
1265
+ // alias can point back into the walked project, where the physical target's
1266
+ // project hash is a different authority (and graph text uses BOM decoding).
1267
+ const recorded = externalHash ?? projectHash;
1268
+ if (recorded === undefined) {
1269
+ return false;
1270
+ }
1271
+ try {
1272
+ const current = graphInput
1273
+ ? graphInputStateHash(input, filesystem)
1274
+ : hostInputStateHash(input, filesystem);
1275
+ return recorded === (current ?? "missing");
1276
+ }
1277
+ catch {
1278
+ return recorded === "missing";
1279
+ }
1280
+ }
1281
+ /** Record a successfully selected module as delivered by this generation. */
1282
+ function markCachedSourceServed(cached, file) {
1283
+ (cached.servedFiles ??= new Set()).add(pathIdentityKey(file, envelopeDerivation(cached).identityContext));
702
1284
  }
703
1285
  /**
704
1286
  * Hash every input file under `projectRoot` (the same walk universe
@@ -706,18 +1288,42 @@ function collectInputHashes(props) {
706
1288
  * slash path. Exported so hosts without a per-build boundary (`@ttsc/metro`)
707
1289
  * can fold the identical input universe into their own cache fingerprints.
708
1290
  */
709
- function collectProjectInputHashes(projectRoot, identities = createHostPathIdentityContext()) {
1291
+ function collectProjectInputHashes(projectRoot, identities = createHostPathIdentityContext(), filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1292
+ return collectProjectInputSnapshot(projectRoot, identities, filesystem)
1293
+ .hashes;
1294
+ }
1295
+ /** Hash project files and snapshot the directory topology in one walk. */
1296
+ function collectProjectInputSnapshot(projectRoot, identities, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
710
1297
  const hashes = {};
711
- for (const file of listProjectInputFiles(projectRoot)) {
1298
+ const fileSignatures = {};
1299
+ const walked = walkProjectInputs(projectRoot, filesystem);
1300
+ let complete = walked.complete;
1301
+ for (const file of walked.files) {
712
1302
  try {
713
- hashes[toProjectKey(projectRoot, file, identities)] = hashText(fs.readFileSync(file));
1303
+ const before = inputMetadataSignature(file, filesystem);
1304
+ const contents = filesystem.readFile(file);
1305
+ const after = inputMetadataSignature(file, filesystem);
1306
+ const key = toProjectKey(projectRoot, file, identities);
1307
+ hashes[key] = hashText(contents);
1308
+ if (before === undefined || after === undefined || before !== after) {
1309
+ complete = false;
1310
+ }
1311
+ else {
1312
+ fileSignatures[key] = after;
1313
+ }
714
1314
  }
715
1315
  catch {
716
1316
  // File watchers may observe a transform while another process is moving
717
1317
  // or deleting files. The missing key invalidates older cache entries.
1318
+ complete = false;
718
1319
  }
719
1320
  }
720
- return hashes;
1321
+ return {
1322
+ complete,
1323
+ fileSignatures,
1324
+ hashes,
1325
+ projectDirectories: walked.directories,
1326
+ };
721
1327
  }
722
1328
  /**
723
1329
  * Enumerate every regular file under `root`, skipping well-known output and
@@ -727,18 +1333,39 @@ function collectProjectInputHashes(projectRoot, identities = createHostPathIdent
727
1333
  * unbounded call-stack depth on deep project trees. The result is sorted so
728
1334
  * that hash comparisons are deterministic across OS-level directory orderings.
729
1335
  */
730
- function listProjectInputFiles(root) {
731
- const out = [];
1336
+ function walkProjectInputs(root, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1337
+ let complete = true;
1338
+ const directories = [];
1339
+ const files = [];
732
1340
  const stack = [root];
733
1341
  while (stack.length !== 0) {
734
1342
  const current = stack.pop();
1343
+ const before = projectDirectorySignature(current, filesystem);
1344
+ if (before === undefined) {
1345
+ complete = false;
1346
+ continue;
1347
+ }
735
1348
  let entries;
736
1349
  try {
737
- entries = fs.readdirSync(current, { withFileTypes: true });
1350
+ entries = filesystem.readdir(current);
738
1351
  }
739
1352
  catch {
1353
+ complete = false;
740
1354
  continue;
741
1355
  }
1356
+ const after = projectDirectorySignature(current, filesystem);
1357
+ if (after === undefined || before !== after) {
1358
+ complete = false;
1359
+ }
1360
+ directories.push({
1361
+ path: current,
1362
+ // If membership moved during enumeration, force the next delivery to
1363
+ // replace this generation instead of blessing a torn directory/file
1364
+ // snapshot as stable.
1365
+ signature: after !== undefined && before === after
1366
+ ? after
1367
+ : `unstable:${before}:${after ?? "missing"}`,
1368
+ });
742
1369
  for (const entry of entries) {
743
1370
  if (isIgnoredProjectDirectory(entry.name)) {
744
1371
  continue;
@@ -748,29 +1375,329 @@ function listProjectInputFiles(root) {
748
1375
  stack.push(file);
749
1376
  }
750
1377
  else if (entry.isFile()) {
751
- out.push(file);
1378
+ files.push(file);
752
1379
  }
753
1380
  }
754
1381
  }
755
- out.sort();
756
- return out;
1382
+ directories.sort((left, right) => left.path.localeCompare(right.path));
1383
+ files.sort();
1384
+ return { complete, directories, files };
1385
+ }
1386
+ /** Return a cheap identity for one directory's immediate membership. */
1387
+ function projectDirectorySignature(directory, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1388
+ try {
1389
+ const stats = filesystem.statBigInt(directory);
1390
+ if (!stats.isDirectory()) {
1391
+ return undefined;
1392
+ }
1393
+ return [
1394
+ stats.dev,
1395
+ stats.ino,
1396
+ stats.mode,
1397
+ stats.size,
1398
+ stats.mtimeNs,
1399
+ stats.ctimeNs,
1400
+ ].join(":");
1401
+ }
1402
+ catch {
1403
+ return undefined;
1404
+ }
1405
+ }
1406
+ /** Compare two deterministic project-directory membership snapshots. */
1407
+ function sameProjectDirectories(left, right) {
1408
+ return (left.length === right.length &&
1409
+ left.every((directory, index) => directory.path === right[index]?.path &&
1410
+ directory.signature === right[index]?.signature));
1411
+ }
1412
+ /** Watch every walked directory for membership changes after generation. */
1413
+ async function createProjectMutationTracker(directories, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1414
+ const tracker = {
1415
+ close: () => undefined,
1416
+ failed: false,
1417
+ membershipChanged: false,
1418
+ };
1419
+ if (process.platform === "win32") {
1420
+ await registerWindowsProjectMutationTracker(tracker, directories.map((directory) => ({ directory: directory.path })), false, filesystem);
1421
+ return tracker;
1422
+ }
1423
+ const watchers = [];
1424
+ tracker.close = () => {
1425
+ for (const watcher of watchers)
1426
+ watcher.close();
1427
+ watchers.length = 0;
1428
+ };
1429
+ for (const directory of directories) {
1430
+ try {
1431
+ const watcher = fs.watch(directory.path, { persistent: false }, (eventType) => {
1432
+ if (eventType === "rename")
1433
+ tracker.membershipChanged = true;
1434
+ });
1435
+ watcher.on("error", () => {
1436
+ tracker.failed = true;
1437
+ });
1438
+ watchers.push(watcher);
1439
+ }
1440
+ catch {
1441
+ tracker.failed = true;
1442
+ }
1443
+ }
1444
+ return tracker;
1445
+ }
1446
+ /** Watch exact universal inputs, or their nearest existing parent if missing. */
1447
+ async function createHostInputMutationTracker(inputs, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1448
+ const identities = createHostPathIdentityContext(filesystem);
1449
+ const namesByDirectory = new Map();
1450
+ for (const input of inputs) {
1451
+ const absolute = path.resolve(input);
1452
+ const probe = filesystem.exists(absolute)
1453
+ ? { directory: path.dirname(absolute), name: path.basename(absolute) }
1454
+ : missingPathProbe(absolute, filesystem);
1455
+ const directoryIdentity = identities.resolve(probe.directory);
1456
+ let location = namesByDirectory.get(directoryIdentity.key);
1457
+ if (location === undefined) {
1458
+ location = {
1459
+ directory: directoryIdentity.path,
1460
+ names: new Set(),
1461
+ };
1462
+ namesByDirectory.set(directoryIdentity.key, location);
1463
+ }
1464
+ location.names.add(normalizeHostInputName(probe.name, identities.caseSensitive(directoryIdentity.path)));
1465
+ }
1466
+ const locations = [...namesByDirectory.values()].map((location) => ({
1467
+ directory: location.directory,
1468
+ names: [...location.names],
1469
+ }));
1470
+ const tracker = {
1471
+ close: () => undefined,
1472
+ failed: false,
1473
+ membershipChanged: false,
1474
+ };
1475
+ if (process.platform === "win32") {
1476
+ await registerWindowsProjectMutationTracker(tracker, locations, true, filesystem);
1477
+ return tracker;
1478
+ }
1479
+ const watchers = [];
1480
+ tracker.close = () => {
1481
+ for (const watcher of watchers)
1482
+ watcher.close();
1483
+ watchers.length = 0;
1484
+ };
1485
+ for (const location of locations) {
1486
+ try {
1487
+ const names = new Set(location.names);
1488
+ const caseSensitive = identities.caseSensitive(location.directory);
1489
+ const watcher = fs.watch(location.directory, { persistent: false }, (_eventType, filename) => {
1490
+ const reported = filename === null
1491
+ ? null
1492
+ : normalizeHostInputName(String(filename), caseSensitive);
1493
+ if (reported === null || names.has(reported)) {
1494
+ tracker.membershipChanged = true;
1495
+ }
1496
+ });
1497
+ watcher.on("error", () => {
1498
+ tracker.failed = true;
1499
+ });
1500
+ watchers.push(watcher);
1501
+ }
1502
+ catch {
1503
+ tracker.failed = true;
1504
+ }
1505
+ }
1506
+ return tracker;
1507
+ }
1508
+ let windowsProjectMutationBroker;
1509
+ /**
1510
+ * Register directory watches in an isolated Windows process.
1511
+ *
1512
+ * Node's Windows fs-event backend can assert in native code when a watched
1513
+ * temporary tree is deleted. Isolation turns that unrecoverable process abort
1514
+ * into an ordinary broker exit and a conservative cache miss in the host.
1515
+ */
1516
+ async function registerWindowsProjectMutationTracker(tracker, locations, allEvents, filesystem) {
1517
+ const broker = getWindowsProjectMutationBroker();
1518
+ const normalized = locations.map((location) => {
1519
+ let directory;
1520
+ try {
1521
+ directory = filesystem.realpath(location.directory);
1522
+ }
1523
+ catch {
1524
+ directory = path.resolve(location.directory);
1525
+ }
1526
+ return {
1527
+ directory,
1528
+ ...(location.names === undefined ? {} : { names: location.names }),
1529
+ };
1530
+ });
1531
+ broker.pendingRegistrations += 1;
1532
+ broker.child.ref();
1533
+ broker.child.channel?.ref();
1534
+ const id = broker.nextId++;
1535
+ let resolveReady;
1536
+ const ready = new Promise((resolve) => {
1537
+ resolveReady = resolve;
1538
+ });
1539
+ broker.trackers.set(id, { ready: resolveReady, tracker });
1540
+ tracker.close = () => {
1541
+ const active = broker.trackers.get(id);
1542
+ if (active === undefined)
1543
+ return;
1544
+ broker.trackers.delete(id);
1545
+ active.ready();
1546
+ broker.child.send?.({ id, op: "remove" });
1547
+ if (broker.trackers.size === 0) {
1548
+ broker.child.disconnect?.();
1549
+ broker.child.kill();
1550
+ if (windowsProjectMutationBroker === broker) {
1551
+ windowsProjectMutationBroker = undefined;
1552
+ }
1553
+ }
1554
+ };
1555
+ broker.child.send?.({
1556
+ allEvents,
1557
+ locations: normalized,
1558
+ id,
1559
+ op: "add",
1560
+ });
1561
+ try {
1562
+ await ready;
1563
+ }
1564
+ finally {
1565
+ broker.pendingRegistrations -= 1;
1566
+ if (broker.pendingRegistrations === 0) {
1567
+ broker.child.unref();
1568
+ broker.child.channel?.unref();
1569
+ }
1570
+ }
1571
+ }
1572
+ function getWindowsProjectMutationBroker() {
1573
+ if (windowsProjectMutationBroker !== undefined) {
1574
+ return windowsProjectMutationBroker;
1575
+ }
1576
+ const child = node_child_process.spawn(process.execPath, ["-e", WINDOWS_WATCH_BROKER_SOURCE], {
1577
+ stdio: ["ignore", "ignore", "ignore", "ipc"],
1578
+ windowsHide: true,
1579
+ });
1580
+ const broker = {
1581
+ child,
1582
+ nextId: 1,
1583
+ pendingRegistrations: 0,
1584
+ trackers: new Map(),
1585
+ };
1586
+ const fail = () => {
1587
+ for (const registration of broker.trackers.values()) {
1588
+ registration.tracker.failed = true;
1589
+ registration.ready();
1590
+ }
1591
+ broker.trackers.clear();
1592
+ if (windowsProjectMutationBroker === broker) {
1593
+ windowsProjectMutationBroker = undefined;
1594
+ }
1595
+ };
1596
+ child.on("error", fail);
1597
+ child.on("exit", fail);
1598
+ child.on("message", (message) => {
1599
+ if (message === null || typeof message !== "object")
1600
+ return;
1601
+ const record = message;
1602
+ if (typeof record.id !== "number")
1603
+ return;
1604
+ const registration = broker.trackers.get(record.id);
1605
+ if (registration === undefined)
1606
+ return;
1607
+ if (record.failed === true)
1608
+ registration.tracker.failed = true;
1609
+ if (record.ready === true)
1610
+ registration.ready();
1611
+ if (record.ready !== true && record.failed !== true) {
1612
+ registration.tracker.membershipChanged = true;
1613
+ }
1614
+ });
1615
+ windowsProjectMutationBroker = broker;
1616
+ return broker;
1617
+ }
1618
+ const WINDOWS_WATCH_BROKER_SOURCE = [
1619
+ 'const fs = require("node:fs");',
1620
+ "const groups = new Map();",
1621
+ 'process.on("message", (message) => {',
1622
+ ' if (message.op === "remove") {',
1623
+ " close(message.id);",
1624
+ " return;",
1625
+ " }",
1626
+ ' if (message.op !== "add") return;',
1627
+ " const watchers = [];",
1628
+ " let failed = false;",
1629
+ " for (const location of message.locations) {",
1630
+ " try {",
1631
+ " const names = location.names === undefined ? undefined : new Set(location.names.map((name) => name.toLowerCase()));",
1632
+ " const watcher = fs.watch(location.directory, { persistent: false }, (event, filename) => {",
1633
+ " const matches = names === undefined || filename === null || names.has(String(filename).toLowerCase());",
1634
+ ' if (matches && (message.allEvents || event === "rename")) process.send?.({ id: message.id });',
1635
+ " });",
1636
+ ' watcher.on("error", () => process.send?.({ failed: true, id: message.id }));',
1637
+ " watchers.push(watcher);",
1638
+ " } catch {",
1639
+ " failed = true;",
1640
+ " }",
1641
+ " }",
1642
+ " groups.set(message.id, watchers);",
1643
+ " process.send?.({ failed, id: message.id, ready: true });",
1644
+ "});",
1645
+ 'process.on("disconnect", () => {',
1646
+ " for (const id of groups.keys()) close(id);",
1647
+ " process.exit(0);",
1648
+ "});",
1649
+ "function close(id) {",
1650
+ " for (const watcher of groups.get(id) ?? []) watcher.close();",
1651
+ " groups.delete(id);",
1652
+ "}",
1653
+ ].join("\n");
1654
+ /** Report whether live directory notifications preserve project membership. */
1655
+ function matchesProjectMembership(cached) {
1656
+ const tracker = cached.projectMutationTracker;
1657
+ return (tracker !== undefined &&
1658
+ tracker.failed === false &&
1659
+ tracker.membershipChanged === false);
1660
+ }
1661
+ /**
1662
+ * Yield once before persistent validation so synchronous edits can reach the
1663
+ * directory watchers that guard membership. Concurrent sibling deliveries share
1664
+ * the same barrier.
1665
+ */
1666
+ async function settleProjectMutationEvents(cached) {
1667
+ const trackers = [
1668
+ cached.projectMutationTracker,
1669
+ cached.hostInputMutationTracker,
1670
+ ].filter((tracker) => tracker !== undefined);
1671
+ await Promise.all(trackers.map(async (tracker) => {
1672
+ tracker.settle ??= new Promise((resolve) => {
1673
+ const settled = () => {
1674
+ tracker.settle = undefined;
1675
+ resolve();
1676
+ };
1677
+ if (process.platform === "win32")
1678
+ setTimeout(settled, 10);
1679
+ else
1680
+ setImmediate(settled);
1681
+ });
1682
+ await tracker.settle;
1683
+ }));
757
1684
  }
758
1685
  /**
759
1686
  * Report whether an absolute `file` belongs to the project walk universe of
760
1687
  * `root`: it lies under `root`, every component exists without traversing a
761
1688
  * symbolic link, the leaf is a regular file, and no segment of the relative
762
- * path is ignored. The predicate mirrors {@link listProjectInputFiles} exactly,
763
- * so "walk-visible" here means "hashed by {@link collectProjectInputHashes}".
1689
+ * path is ignored. The predicate mirrors {@link walkProjectInputs} exactly, so
1690
+ * "walk-visible" here means "hashed by {@link collectProjectInputHashes}".
764
1691
  * Missing paths and files reached through symlinks or Windows junctions are
765
1692
  * out-of-walk inputs that only the reference graph can prove relevant.
766
1693
  */
767
- function isProjectWalkPath(root, file, identities = createHostPathIdentityContext()) {
768
- if (!identities.isWithin(root, file)) {
769
- return false;
770
- }
771
- const rootKey = pathIdentityKey(root, identities);
772
- const fileKey = pathIdentityKey(file, identities);
773
- const relative = fileKey.slice(rootKey.length).replace(/^[/\\]+/, "");
1694
+ function isProjectWalkPath(root, file, _identities = createHostPathIdentityContext(), filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1695
+ // Walk membership is lexical. Resolving `file` to physical identity first
1696
+ // would turn `root/alias/value.ts` into `root/target/value.ts`, hide the
1697
+ // symlink segment from the lstat loop below, and falsely claim the project
1698
+ // walk hashed a path it deliberately never followed.
1699
+ const resolvedRoot = path.resolve(root);
1700
+ const relative = path.relative(resolvedRoot, path.resolve(file));
774
1701
  if (relative.length === 0 ||
775
1702
  relative === ".." ||
776
1703
  relative.startsWith(`..${path.sep}`) ||
@@ -781,12 +1708,12 @@ function isProjectWalkPath(root, file, identities = createHostPathIdentityContex
781
1708
  if (segments.some(isIgnoredProjectDirectory)) {
782
1709
  return false;
783
1710
  }
784
- let current = path.resolve(root);
1711
+ let current = resolvedRoot;
785
1712
  for (let index = 0; index < segments.length; ++index) {
786
1713
  current = path.join(current, segments[index]);
787
1714
  let stats;
788
1715
  try {
789
- stats = fs.lstatSync(current);
1716
+ stats = filesystem.lstat(current);
790
1717
  }
791
1718
  catch {
792
1719
  return false;
@@ -803,27 +1730,41 @@ function isProjectWalkPath(root, file, identities = createHostPathIdentityContex
803
1730
  }
804
1731
  /**
805
1732
  * Hash a list of absolute out-of-walk input paths: content SHA-256 for a
806
- * readable file, a stable `missing` marker otherwise. Keys use filesystem
807
- * identity so case-only spellings share one snapshot entry, while reads retain
808
- * the original path supplied by the compiler. The marker is state, not an error
809
- * a recorded input disappearing (or reappearing) must change the comparison
810
- * exactly like a content edit. Exported so `@ttsc/metro` can re-hash its
811
- * recorded snapshot with identical semantics at cache-key time.
1733
+ * readable file, a stable directory-kind digest for a directory candidate, and
1734
+ * a stable `missing` marker otherwise. Keys use filesystem identity so
1735
+ * case-only spellings share one snapshot entry, while reads retain the original
1736
+ * path supplied by the compiler. The marker is state, not an error — a recorded
1737
+ * input disappearing (or reappearing) must change the comparison exactly like a
1738
+ * content edit. Exported so `@ttsc/metro` can re-hash its recorded snapshot
1739
+ * with identical semantics at cache-key time.
812
1740
  */
813
- function collectExternalInputHashes(paths) {
1741
+ function collectExternalInputHashes(paths, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
814
1742
  const hashes = {};
815
- const identities = createHostPathIdentityContext();
1743
+ const identities = createHostPathIdentityContext(filesystem);
816
1744
  for (const file of paths) {
817
1745
  const identity = pathIdentityKey(file, identities);
818
1746
  if (identity in hashes) {
819
1747
  continue;
820
1748
  }
821
- try {
822
- hashes[identity] = hashText(fs.readFileSync(file));
823
- }
824
- catch {
825
- hashes[identity] = "missing";
826
- }
1749
+ hashes[identity] = hostInputStateHash(file, filesystem) ?? "missing";
1750
+ }
1751
+ return hashes;
1752
+ }
1753
+ /** Re-hash a cached mixed graph/dependency input set with its owning codec. */
1754
+ function collectCachedExternalInputHashes(cached) {
1755
+ const hashes = {};
1756
+ const state = envelopeDerivation(cached);
1757
+ const graphRealpaths = cached.externalInputRealpaths ?? {};
1758
+ const filesystem = resultFilesystem(cached.result);
1759
+ for (const file of cached.externalInputPaths ??
1760
+ Object.keys(cached.externalInputHashes ?? {})) {
1761
+ const identity = derivationIdentity(state, file);
1762
+ if (identity in hashes)
1763
+ continue;
1764
+ hashes[identity] =
1765
+ (Object.prototype.hasOwnProperty.call(graphRealpaths, identity)
1766
+ ? graphInputStateHash(file, filesystem)
1767
+ : hostInputStateHash(file, filesystem)) ?? "missing";
827
1768
  }
828
1769
  return hashes;
829
1770
  }
@@ -836,21 +1777,19 @@ function collectExternalInputHashes(paths) {
836
1777
  * that are still missing remain in this set even under the project root: the
837
1778
  * first walk cannot hash a file that has not been created yet.
838
1779
  *
839
- * A `dependenciesComplete` declaration deliberately does not narrow this set,
840
- * unlike the per-file watch derivation. This cache replays one whole envelope,
841
- * so its validity condition is the union over every file the envelope carries
842
- * rather than one file's inputs; a miss here costs a re-transform, never a
843
- * stale output; and it is the layer that re-runs the plugin's analysis, which
844
- * is how a widened declaration is ever learned. The narrowing that matters
845
- * lands at the bundler boundary through {@link selectWatchInputs}, which is what
846
- * feeds persistent caches and watch graphs.
1780
+ * A `dependenciesComplete` declaration deliberately does not narrow the stored
1781
+ * set: other files in the same whole-project result can still own the omitted
1782
+ * members. Persistent validation selects the requested file's subset through
1783
+ * {@link selectWatchInputs}, while graph-free envelopes use this union as their
1784
+ * conservative fallback.
847
1785
  */
848
1786
  function selectExternalInputPaths(props) {
849
1787
  if (props.result.type === "exception") {
850
1788
  return [];
851
1789
  }
852
1790
  const members = [];
853
- const identities = createHostPathIdentityContext();
1791
+ const filesystem = props.filesystem ?? DEFAULT_FILESYSTEM_OPERATIONS;
1792
+ const identities = createHostPathIdentityContext(filesystem);
854
1793
  const resolutionCandidates = new Set();
855
1794
  const graph = props.result.graph;
856
1795
  if (graph !== undefined) {
@@ -884,6 +1823,17 @@ function selectExternalInputPaths(props) {
884
1823
  members.push(...entries);
885
1824
  }
886
1825
  }
1826
+ if (Array.isArray(props.result.hostInputs)) {
1827
+ for (const input of props.result.hostInputs) {
1828
+ members.push(input);
1829
+ if (typeof input === "string" && input.length !== 0) {
1830
+ // Plugin discovery inputs deliberately include absent config and
1831
+ // resolution probes. A project walk cannot snapshot a path that does
1832
+ // not exist yet, even when its spelling lies below projectRoot.
1833
+ resolutionCandidates.add(pathIdentityKey(path.resolve(props.projectRoot, input), identities));
1834
+ }
1835
+ }
1836
+ }
887
1837
  const excluded = props.temporaryTsconfig === undefined
888
1838
  ? undefined
889
1839
  : pathIdentityKey(props.temporaryTsconfig, identities);
@@ -894,15 +1844,18 @@ function selectExternalInputPaths(props) {
894
1844
  continue;
895
1845
  }
896
1846
  const absolute = path.resolve(props.projectRoot, member);
1847
+ const spelling = path.resolve(absolute);
897
1848
  const identity = pathIdentityKey(absolute, identities);
898
- const missingCandidate = resolutionCandidates.has(identity) && !fs.existsSync(absolute);
1849
+ const missingCandidate = resolutionCandidates.has(identity) && !filesystem.exists(absolute);
899
1850
  if (identity === excluded ||
900
- seen.has(identity) ||
1851
+ seen.has(spelling) ||
901
1852
  (!missingCandidate &&
902
- isProjectWalkPath(props.projectRoot, absolute, identities))) {
1853
+ isProjectWalkPath(props.projectRoot, absolute, identities, filesystem))) {
903
1854
  continue;
904
1855
  }
905
- seen.add(identity);
1856
+ // Preserve distinct lexical aliases even when they currently select the
1857
+ // same physical file. A later retarget must validate the alias itself.
1858
+ seen.add(spelling);
906
1859
  output.push(absolute);
907
1860
  }
908
1861
  output.sort();
@@ -937,12 +1890,22 @@ function hashText(input) {
937
1890
  return crypto.createHash("sha256").update(input).digest("hex");
938
1891
  }
939
1892
  async function transformProject(props) {
940
- const configured = createTransformTsconfig(props);
941
1893
  const projectRoot = path.dirname(props.tsconfig);
1894
+ const scratchDirectory = createTransformScratchDirectory(projectRoot, props.filesystem);
1895
+ let tracker;
1896
+ let retainTracker = false;
1897
+ let hostInputTracker;
942
1898
  try {
943
- const result = new ttsc.TtscCompiler({
1899
+ const configured = createTransformTsconfig(props, scratchDirectory);
1900
+ const temporaryTsconfig = configured.path === props.tsconfig ? undefined : configured.path;
1901
+ const identities = createHostPathIdentityContext(props.filesystem);
1902
+ const before = collectProjectInputSnapshot(projectRoot, identities, props.filesystem);
1903
+ tracker = props.trackProjectMembership
1904
+ ? await createProjectMutationTracker(before.projectDirectories, props.filesystem)
1905
+ : undefined;
1906
+ const result = withTransformScratchEnvironment(scratchDirectory, () => new ttsc.TtscCompiler({
944
1907
  cwd: projectRoot,
945
- // The generated tsconfig (if any) lives in the system temp directory,
1908
+ // The generated tsconfig (if any) lives outside the project directory,
946
1909
  // so declare the real project as the plugin config anchor: utility
947
1910
  // plugin config discovery (banner.config.*, strip.config.*,
948
1911
  // lint.config.*) and relative configFile resolution walk the project,
@@ -952,24 +1915,47 @@ async function transformProject(props) {
952
1915
  plugins: props.plugins,
953
1916
  projectRoot,
954
1917
  tsconfig: configured.path,
955
- }).transform();
956
- const temporaryTsconfig = configured.path === props.tsconfig ? undefined : configured.path;
1918
+ env: transformScratchEnvironment(scratchDirectory),
1919
+ }).transform());
1920
+ TRANSFORM_RESULT_FILESYSTEM.set(result, props.filesystem);
1921
+ const persistentHostInputs = selectPersistentHostInputs({
1922
+ filesystem: props.filesystem,
1923
+ projectRoot,
1924
+ result,
1925
+ temporaryTsconfig,
1926
+ });
1927
+ hostInputTracker = props.trackProjectMembership
1928
+ ? await createHostInputMutationTracker(persistentHostInputs, props.filesystem)
1929
+ : undefined;
957
1930
  const externalInputPaths = selectExternalInputPaths({
1931
+ filesystem: props.filesystem,
958
1932
  projectRoot,
959
1933
  result,
960
1934
  temporaryTsconfig,
961
1935
  });
962
- return {
1936
+ const inputSnapshot = collectProjectInputSnapshot(projectRoot, identities, props.filesystem);
1937
+ let stableProjectSnapshot = before.complete &&
1938
+ inputSnapshot.complete &&
1939
+ sameHashes(before.hashes, inputSnapshot.hashes) &&
1940
+ sameHashes(before.fileSignatures, inputSnapshot.fileSignatures) &&
1941
+ sameProjectDirectories(before.projectDirectories, inputSnapshot.projectDirectories) &&
1942
+ tracker?.failed !== true &&
1943
+ tracker?.membershipChanged !== true &&
1944
+ hostInputTracker?.failed !== true &&
1945
+ hostInputTracker?.membershipChanged !== true;
1946
+ // Overlay the in-memory source only after proving the two on-disk snapshots
1947
+ // stable; an unsaved editor buffer must not look like a compile-time race.
1948
+ inputSnapshot.hashes[toProjectKey(projectRoot, props.currentFile, identities)] = hashText(props.currentSource);
1949
+ const cached = {
963
1950
  // Capture the out-of-walk input hashes while the generation is fresh so
964
1951
  // cache validation can re-check them; computed before dispose so the
965
1952
  // exclusion of the temp-dir tsconfig is the only reason it never keys.
966
- externalInputHashes: collectExternalInputHashes(externalInputPaths),
1953
+ externalInputHashes: {},
1954
+ externalInputRealpaths: {},
967
1955
  externalInputPaths,
968
- inputHashes: collectInputHashes({
969
- currentFile: props.currentFile,
970
- currentSource: props.currentSource,
971
- projectRoot,
972
- }),
1956
+ inputHashes: inputSnapshot.hashes,
1957
+ projectDirectories: inputSnapshot.projectDirectories,
1958
+ projectSnapshotComplete: false,
973
1959
  projectRoot,
974
1960
  result,
975
1961
  servedFiles: new Set(),
@@ -978,40 +1964,186 @@ async function transformProject(props) {
978
1964
  // but deleted file would invalidate every persistent-cache snapshot.
979
1965
  ...(temporaryTsconfig === undefined ? {} : { temporaryTsconfig }),
980
1966
  };
1967
+ const externalInputSnapshot = captureExternalInputSnapshot(cached, externalInputPaths);
1968
+ cached.externalInputHashes = externalInputSnapshot.hashes;
1969
+ cached.externalInputRealpaths = externalInputSnapshot.realpaths;
1970
+ stableProjectSnapshot =
1971
+ stableProjectSnapshot &&
1972
+ matchesCompilerGraphInputProofs(cached) &&
1973
+ externalInputSnapshot.complete &&
1974
+ captureUniversalHostInputValidation(cached, props.currentFile) !==
1975
+ undefined;
1976
+ cached.projectSnapshotComplete = stableProjectSnapshot;
1977
+ if (stableProjectSnapshot && tracker !== undefined) {
1978
+ cached.projectMutationTracker = tracker;
1979
+ }
1980
+ if (stableProjectSnapshot && hostInputTracker !== undefined) {
1981
+ cached.hostInputMutationTracker = hostInputTracker;
1982
+ }
1983
+ retainTracker =
1984
+ stableProjectSnapshot &&
1985
+ tracker !== undefined &&
1986
+ hostInputTracker !== undefined;
1987
+ return cached;
981
1988
  }
982
1989
  finally {
983
- configured.dispose();
1990
+ try {
1991
+ if (!retainTracker && tracker !== undefined) {
1992
+ tracker.close();
1993
+ }
1994
+ }
1995
+ finally {
1996
+ try {
1997
+ if (!retainTracker && hostInputTracker !== undefined) {
1998
+ hostInputTracker.close();
1999
+ }
2000
+ }
2001
+ finally {
2002
+ fs.rmSync(scratchDirectory, { force: true, recursive: true });
2003
+ }
2004
+ }
984
2005
  }
985
2006
  }
986
- function createTransformTsconfig(props) {
2007
+ /** Exclude the disposed overlay tsconfig from live host-input tracking. */
2008
+ function selectPersistentHostInputs(props) {
2009
+ if (props.result.type === "exception")
2010
+ return [];
2011
+ const inputs = selectListedFiles(props.projectRoot, props.result.hostInputs);
2012
+ if (props.temporaryTsconfig === undefined)
2013
+ return inputs;
2014
+ const identities = createHostPathIdentityContext(props.filesystem);
2015
+ const temporary = pathIdentityKey(props.temporaryTsconfig, identities);
2016
+ return inputs.filter((input) => pathIdentityKey(input, identities) !== temporary);
2017
+ }
2018
+ function createTransformTsconfig(props, scratchDirectory) {
987
2019
  const compilerOptions = normalizeCompilerOptionsForGeneratedTsconfig({
988
2020
  ...props.compilerOptions,
989
2021
  ...createAliasCompilerOptions(props),
990
2022
  }, path.dirname(props.tsconfig));
991
2023
  if (Object.keys(compilerOptions).length === 0) {
992
- return {
993
- path: props.tsconfig,
994
- dispose: () => undefined,
995
- };
2024
+ return { path: props.tsconfig };
996
2025
  }
997
- const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ttsc-unplugin-"));
998
- const file = path.join(directory, "tsconfig.json");
2026
+ const file = path.join(scratchDirectory, "tsconfig.json");
999
2027
  fs.writeFileSync(file, JSON.stringify({
1000
2028
  extends: normalizePath(props.tsconfig),
1001
2029
  compilerOptions,
1002
2030
  }, null, 2), "utf8");
2031
+ return { path: file };
2032
+ }
2033
+ /** Create compiler scratch storage outside the project snapshot and watchers. */
2034
+ function createTransformScratchDirectory(projectRoot, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
2035
+ const root = path.resolve(projectRoot);
2036
+ const canonicalRoot = filesystem.realpath(root);
2037
+ const platformTemp = process.platform === "win32" && process.env.LOCALAPPDATA
2038
+ ? path.join(process.env.LOCALAPPDATA, "Temp")
2039
+ : "/tmp";
2040
+ const candidates = [
2041
+ os.tmpdir(),
2042
+ platformTemp,
2043
+ path.dirname(root),
2044
+ os.homedir(),
2045
+ ];
2046
+ const canonicalCandidates = new Set();
2047
+ let failure;
2048
+ for (const candidate of new Set(candidates.map((dir) => path.resolve(dir)))) {
2049
+ if (pathIsWithin(candidate, root))
2050
+ continue;
2051
+ let canonicalCandidate;
2052
+ try {
2053
+ canonicalCandidate = filesystem.realpath(candidate);
2054
+ }
2055
+ catch (error) {
2056
+ failure = error;
2057
+ continue;
2058
+ }
2059
+ if (pathIsWithin(canonicalCandidate, canonicalRoot) ||
2060
+ canonicalCandidates.has(canonicalCandidate)) {
2061
+ continue;
2062
+ }
2063
+ canonicalCandidates.add(canonicalCandidate);
2064
+ let directory;
2065
+ try {
2066
+ directory = fs.mkdtempSync(path.join(canonicalCandidate, "ttsc-unplugin-"));
2067
+ }
2068
+ catch (error) {
2069
+ failure = error;
2070
+ continue;
2071
+ }
2072
+ let canonicalDirectory;
2073
+ try {
2074
+ canonicalDirectory = filesystem.realpath(directory);
2075
+ }
2076
+ catch (error) {
2077
+ try {
2078
+ fs.rmdirSync(directory);
2079
+ }
2080
+ catch (cleanupError) {
2081
+ throw cleanupError;
2082
+ }
2083
+ failure = error;
2084
+ continue;
2085
+ }
2086
+ // Use the postflight canonical spelling from this point onward. Returning
2087
+ // the candidate-relative spelling would let another process retarget its
2088
+ // parent symlink/junction after validation, redirecting compiler writes or
2089
+ // the final recursive removal into the project.
2090
+ if (!pathIsWithin(canonicalDirectory, canonicalRoot)) {
2091
+ return canonicalDirectory;
2092
+ }
2093
+ // Refuse the result and synchronously remove only our empty random child
2094
+ // through the identity that the postflight check just classified.
2095
+ fs.rmdirSync(canonicalDirectory);
2096
+ }
2097
+ throw (failure ??
2098
+ new Error("ttsc: no temporary directory exists outside the project"));
2099
+ }
2100
+ function pathIsWithin(child, parent) {
2101
+ const relative = path.relative(parent, child);
2102
+ return (relative === "" ||
2103
+ (relative !== ".." &&
2104
+ !relative.startsWith(`..${path.sep}`) &&
2105
+ !path.isAbsolute(relative)));
2106
+ }
2107
+ /** Route all compiler/plugin scratch to one owned directory outside project. */
2108
+ function transformScratchEnvironment(directory) {
1003
2109
  return {
1004
- path: file,
1005
- dispose: () => fs.rmSync(directory, { force: true, recursive: true }),
2110
+ ...process.env,
2111
+ TEMP: directory,
2112
+ TMP: directory,
2113
+ TMPDIR: directory,
1006
2114
  };
1007
2115
  }
2116
+ /** Scope parent-process temp consumers to the same owned scratch directory. */
2117
+ function withTransformScratchEnvironment(scratchDirectory, callback) {
2118
+ const environment = transformScratchEnvironment(scratchDirectory);
2119
+ const previous = {
2120
+ TEMP: process.env.TEMP,
2121
+ TMP: process.env.TMP,
2122
+ TMPDIR: process.env.TMPDIR,
2123
+ };
2124
+ process.env.TEMP = environment.TEMP;
2125
+ process.env.TMP = environment.TMP;
2126
+ process.env.TMPDIR = environment.TMPDIR;
2127
+ try {
2128
+ return callback();
2129
+ }
2130
+ finally {
2131
+ for (const [name, value] of Object.entries(previous)) {
2132
+ if (value === undefined)
2133
+ delete process.env[name];
2134
+ else
2135
+ process.env[name] = value;
2136
+ }
2137
+ }
2138
+ }
1008
2139
  /**
1009
2140
  * Resolve all relative paths inside `compilerOptions` against `tsconfigDir`.
1010
2141
  *
1011
- * The generated tsconfig lives in a system temp directory, so any relative path
1012
- * (e.g. `"outDir": "../dist"`) that was meaningful relative to the original
1013
- * tsconfig must be converted to an absolute path before writing the generated
1014
- * file. Otherwise TypeScript-Go resolves it against the temp dir.
2142
+ * The generated tsconfig lives in a temporary directory outside the project, so
2143
+ * any relative path (e.g. `"outDir": "../dist"`) that was meaningful relative
2144
+ * to the original tsconfig must be converted to an absolute path before writing
2145
+ * the generated file. Otherwise TypeScript-Go resolves it against the temp
2146
+ * dir.
1015
2147
  *
1016
2148
  * `paths` targets are absolutized for the same reason, with the extra twist
1017
2149
  * that TypeScript-Go rejects bare non-relative targets outright (TS5090) and
@@ -1284,7 +2416,7 @@ function formatUnknownError(error) {
1284
2416
  * compiler will error if that file does not exist, which is the correct
1285
2417
  * behavior for a mis-configured project.
1286
2418
  */
1287
- function resolveTsconfig(file, tsconfig) {
2419
+ function resolveTsconfig(file, tsconfig, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1288
2420
  if (tsconfig !== undefined) {
1289
2421
  return path.isAbsolute(tsconfig)
1290
2422
  ? tsconfig
@@ -1293,7 +2425,7 @@ function resolveTsconfig(file, tsconfig) {
1293
2425
  let current = path.dirname(file);
1294
2426
  while (true) {
1295
2427
  const candidate = path.join(current, "tsconfig.json");
1296
- if (fs.existsSync(candidate)) {
2428
+ if (filesystem.exists(candidate)) {
1297
2429
  return candidate;
1298
2430
  }
1299
2431
  const parent = path.dirname(current);
@@ -1332,6 +2464,7 @@ exports.createTransformResult = createTransformResult;
1332
2464
  exports.createTtscTransformCache = createTtscTransformCache;
1333
2465
  exports.isDeclarationFile = isDeclarationFile;
1334
2466
  exports.isProjectWalkPath = isProjectWalkPath;
2467
+ exports.normalizeHostInputName = normalizeHostInputName;
1335
2468
  exports.pathIdentityKey = pathIdentityKey;
1336
2469
  exports.resetTtscTransformCache = resetTtscTransformCache;
1337
2470
  exports.stripQuery = stripQuery;