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