@ttsc/unplugin 0.26.2 → 0.28.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,59 @@ 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
+ watch: operations.watch,
54
+ });
55
+ return cache;
56
+ }
57
+ function transformFilesystem(cache) {
58
+ return ((cache === undefined ? undefined : TRANSFORM_CACHE_FILESYSTEM.get(cache)) ??
59
+ DEFAULT_FILESYSTEM_OPERATIONS);
60
+ }
61
+ function resultFilesystem(result) {
62
+ return (TRANSFORM_RESULT_FILESYSTEM.get(result) ?? DEFAULT_FILESYSTEM_OPERATIONS);
22
63
  }
23
64
  /**
24
65
  * Start a host build, clearing its prior generation and enabling constant-time
@@ -29,7 +70,7 @@ function createTtscTransformCache() {
29
70
  * defines one process-scoped module-loading session.
30
71
  */
31
72
  function beginTtscTransformBuild(cache) {
32
- cache.clear();
73
+ clearTtscTransformCache(cache);
33
74
  BUILD_SCOPED_TRANSFORM_CACHES.add(cache);
34
75
  }
35
76
  /**
@@ -40,9 +81,17 @@ function beginTtscTransformBuild(cache) {
40
81
  * many edits, so that callback cannot authorize build-scoped shortcuts.
41
82
  */
42
83
  function resetTtscTransformCache(cache) {
43
- cache.clear();
84
+ clearTtscTransformCache(cache);
44
85
  BUILD_SCOPED_TRANSFORM_CACHES.delete(cache);
45
86
  }
87
+ /** Dispose generation-owned filesystem resources before clearing a cache. */
88
+ function clearTtscTransformCache(cache) {
89
+ const generations = [...cache.values()];
90
+ cache.clear();
91
+ for (const generation of generations) {
92
+ void generation.then(disposeCachedTransform, () => undefined);
93
+ }
94
+ }
46
95
  /**
47
96
  * Apply the ttsc plugin transform to a single source file.
48
97
  *
@@ -68,6 +117,7 @@ function resetTtscTransformCache(cache) {
68
117
  * per build, not per compilation.
69
118
  */
70
119
  async function transformTtsc(id, source, options, aliases, cache, hooks) {
120
+ const filesystem = transformFilesystem(cache);
71
121
  const clean = stripQuery(id);
72
122
  if (clean.includes("\0")) {
73
123
  return undefined;
@@ -79,7 +129,7 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
79
129
  if (pluginsAreDisabled(options.plugins)) {
80
130
  return undefined;
81
131
  }
82
- const tsconfig = resolveTsconfig(file, options.project);
132
+ const tsconfig = resolveTsconfig(file, options.project, filesystem);
83
133
  const aliasPaths = createAliasPaths(aliases);
84
134
  const key = createTransformCacheKey({
85
135
  aliasPaths,
@@ -93,11 +143,19 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
93
143
  // A rejected in-flight generation must not stay cached: evict it (only if
94
144
  // it is still the current entry) so a later call re-runs the transform.
95
145
  const cached = await awaitOrEvict(cache, key, transformed);
146
+ TRANSFORM_RESULT_FILESYSTEM.set(cached.result, filesystem);
96
147
  // While this caller awaited the old Promise, another caller may have
97
148
  // invalidated it and installed a newer authoritative generation.
98
149
  if (cache?.get(key) !== transformed) {
99
150
  continue;
100
151
  }
152
+ const buildScoped = cache !== undefined && BUILD_SCOPED_TRANSFORM_CACHES.has(cache);
153
+ if (!buildScoped) {
154
+ await settleProjectMutationEvents(cached);
155
+ if (cache?.get(key) !== transformed) {
156
+ continue;
157
+ }
158
+ }
101
159
  if (
102
160
  // A file the plugin declared volatile must never be served from the
103
161
  // cache: its output depends on non-file inputs, so the input-hash
@@ -107,7 +165,7 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
107
165
  projectRoot: cached.projectRoot,
108
166
  result: cached.result,
109
167
  }) &&
110
- matchesCachedSource(cached, file, source, cache !== undefined && BUILD_SCOPED_TRANSFORM_CACHES.has(cache))) {
168
+ matchesCachedSource(cached, file, source, buildScoped)) {
111
169
  reportSuccessDiagnostics(cached.result);
112
170
  // A resolved `"exception"` / `"failure"` envelope makes this throw;
113
171
  // that is a failed generation too, so evict before surfacing it.
@@ -116,12 +174,7 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
116
174
  projectRoot: cached.projectRoot,
117
175
  result: cached.result,
118
176
  });
119
- notifyWatchInputs(hooks, {
120
- file,
121
- projectRoot: cached.projectRoot,
122
- result: cached.result,
123
- temporaryTsconfig: cached.temporaryTsconfig,
124
- });
177
+ notifyWatchInputs(hooks, cached, file);
125
178
  markCachedSourceServed(cached, file);
126
179
  return createTransformResult(source, code);
127
180
  }
@@ -140,7 +193,9 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
140
193
  compilerOptions: options.compilerOptions,
141
194
  currentFile: file,
142
195
  currentSource: source,
196
+ filesystem,
143
197
  plugins: options.plugins,
198
+ trackProjectMembership: cache !== undefined,
144
199
  tsconfig,
145
200
  });
146
201
  cache?.set(key, transformed);
@@ -150,14 +205,14 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
150
205
  if (cache !== undefined && cache.get(key) !== generation) {
151
206
  continue;
152
207
  }
153
- const { projectRoot, result, temporaryTsconfig } = cached;
208
+ const { projectRoot, result } = cached;
154
209
  reportSuccessDiagnostics(result);
155
210
  const code = selectOrEvict(cache, key, generation, {
156
211
  file,
157
212
  projectRoot,
158
213
  result,
159
214
  });
160
- notifyWatchInputs(hooks, { file, projectRoot, result, temporaryTsconfig });
215
+ notifyWatchInputs(hooks, cached, file);
161
216
  markCachedSourceServed(cached, file);
162
217
  if (isVolatileFile(envelopeDerivation(cached), { file, projectRoot, result })) {
163
218
  hooks?.markVolatile?.();
@@ -207,8 +262,22 @@ function selectOrEvict(cache, key, generation, props) {
207
262
  function evictGeneration(cache, key, generation) {
208
263
  if (cache?.get(key) === generation) {
209
264
  cache.delete(key);
265
+ void generation.then(disposeCachedTransform, () => undefined);
210
266
  }
211
267
  }
268
+ /** Close one generation's directory watchers exactly once. */
269
+ function disposeCachedTransform(cached) {
270
+ const trackers = [
271
+ cached.projectMutationTracker,
272
+ cached.hostInputMutationTracker,
273
+ cached.candidateMutationTracker,
274
+ ];
275
+ cached.projectMutationTracker = undefined;
276
+ cached.hostInputMutationTracker = undefined;
277
+ cached.candidateMutationTracker = undefined;
278
+ for (const tracker of trackers)
279
+ tracker?.close();
280
+ }
212
281
  /**
213
282
  * Derivation states keyed by the compiler result object. One result object is
214
283
  * produced by one compile against one project root, so the root captured at
@@ -222,7 +291,7 @@ function envelopeDerivation(props) {
222
291
  return existing;
223
292
  }
224
293
  const created = {
225
- identityContext: createHostPathIdentityContext(),
294
+ identityContext: createHostPathIdentityContext(resultFilesystem(props.result)),
226
295
  identities: new Map(),
227
296
  watchInputs: new Map(),
228
297
  };
@@ -244,6 +313,10 @@ function envelopeGraphIndexes(state, props) {
244
313
  candidates: [],
245
314
  globals: [],
246
315
  configs: [],
316
+ members: new Set(),
317
+ speculative: new Set(),
318
+ inputProofs: new Map(),
319
+ inputProofConflicts: new Set(),
247
320
  };
248
321
  const graph = props.result.type === "exception" ? undefined : props.result.graph;
249
322
  if (graph !== undefined) {
@@ -253,23 +326,83 @@ function envelopeGraphIndexes(state, props) {
253
326
  }
254
327
  const absolute = path.resolve(props.projectRoot, source);
255
328
  const identity = derivationIdentity(state, absolute);
329
+ built.members.add(identity);
256
330
  built.spellings.set(identity, absolute);
257
331
  const entries = built.edges.get(identity) ?? [];
258
332
  entries.push(...targets
259
333
  .filter((target) => typeof target === "string" && target.length !== 0)
260
- .map((target) => path.resolve(props.projectRoot, target)));
334
+ .map((target) => {
335
+ const absoluteTarget = path.resolve(props.projectRoot, target);
336
+ built.members.add(derivationIdentity(state, absoluteTarget));
337
+ return absoluteTarget;
338
+ }));
261
339
  built.edges.set(identity, entries);
262
340
  }
263
341
  built.globals.push(...selectListedFiles(props.projectRoot, graph.globals));
264
342
  built.configs.push(...selectListedFiles(props.projectRoot, graph.configs));
265
- for (const [source, candidates] of Object.entries(graph.candidates ?? {})) {
266
- if (!Array.isArray(candidates)) {
267
- continue;
268
- }
343
+ for (const input of [...built.globals, ...built.configs]) {
344
+ built.members.add(derivationIdentity(state, input));
345
+ }
346
+ const candidateEntries = Object.entries(graph.candidates ?? {}).filter((entry) => Array.isArray(entry[1]));
347
+ // Every candidate source is an importing file the compiler read, so fold
348
+ // the sources in before classifying any candidate. Otherwise one entry's
349
+ // candidate could be classified speculative before a later entry proves
350
+ // the same path is a realized source.
351
+ for (const [source] of candidateEntries) {
352
+ built.members.add(derivationIdentity(state, path.resolve(props.projectRoot, source)));
353
+ }
354
+ const realized = new Set(built.members);
355
+ for (const [source, candidates] of candidateEntries) {
269
356
  built.candidates.push({
270
357
  source: derivationIdentity(state, path.resolve(props.projectRoot, source)),
271
358
  files: selectListedFiles(props.projectRoot, candidates),
272
359
  });
360
+ for (const candidate of candidates) {
361
+ if (typeof candidate !== "string" || candidate.length === 0)
362
+ continue;
363
+ const identity = derivationIdentity(state, path.resolve(props.projectRoot, candidate));
364
+ // Edges, globals, configs, and every candidate source are folded in
365
+ // above, so a path absent from that set is one the envelope reported
366
+ // only as a candidate.
367
+ if (!realized.has(identity))
368
+ built.speculative.add(identity);
369
+ built.members.add(identity);
370
+ }
371
+ }
372
+ for (const [input, hash] of Object.entries(graph.inputHashes ?? {})) {
373
+ if (hash !== null &&
374
+ (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash))) {
375
+ continue;
376
+ }
377
+ if (graph.inputRealpaths === undefined ||
378
+ !Object.prototype.hasOwnProperty.call(graph.inputRealpaths, input)) {
379
+ continue;
380
+ }
381
+ const reportedRealpath = graph.inputRealpaths[input];
382
+ if (reportedRealpath !== null &&
383
+ (typeof reportedRealpath !== "string" ||
384
+ !path.isAbsolute(reportedRealpath))) {
385
+ continue;
386
+ }
387
+ const absolute = path.resolve(props.projectRoot, input);
388
+ const identity = derivationIdentity(state, absolute);
389
+ if (!built.members.has(identity))
390
+ continue;
391
+ const proof = {
392
+ hash,
393
+ path: absolute,
394
+ realpath: reportedRealpath === null ? null : path.resolve(reportedRealpath),
395
+ };
396
+ const previous = built.inputProofs.get(identity);
397
+ if (previous !== undefined &&
398
+ (previous.hash !== proof.hash ||
399
+ !sameHostInputRealpath(previous.realpath, proof.realpath, state.identityContext))) {
400
+ built.inputProofs.delete(identity);
401
+ built.inputProofConflicts.add(identity);
402
+ }
403
+ else if (!built.inputProofConflicts.has(identity)) {
404
+ built.inputProofs.set(identity, proof);
405
+ }
273
406
  }
274
407
  }
275
408
  state.graph = built;
@@ -320,13 +453,29 @@ function collectDeclaredIdentities(state, projectRoot, listed) {
320
453
  * watches the module it transforms), and so is the disposed temp-dir tsconfig
321
454
  * (see {@link TtscCachedProjectTransform.temporaryTsconfig}).
322
455
  */
323
- function notifyWatchInputs(hooks, props) {
456
+ function notifyWatchInputs(hooks, cached, file) {
324
457
  const addWatchFile = hooks?.addWatchFile;
325
458
  if (addWatchFile === undefined) {
326
459
  return;
327
460
  }
328
- for (const input of selectWatchInputs(props)) {
329
- addWatchFile(input);
461
+ const state = envelopeDerivation(cached);
462
+ const external = cached.externalInputHashes ?? {};
463
+ for (const input of selectWatchInputs({
464
+ file,
465
+ projectRoot: cached.projectRoot,
466
+ result: cached.result,
467
+ temporaryTsconfig: cached.temporaryTsconfig,
468
+ })) {
469
+ // Hand the adapter the identity this generation already resolved and the
470
+ // existence state it already recorded. Both are memoized per generation,
471
+ // while an adapter deriving them itself pays a `realpath`, a directory
472
+ // listing, and an `existsSync` per input on every delivery of every module
473
+ // (samchon/ttsc#1246).
474
+ const identity = derivationIdentity(state, input);
475
+ addWatchFile(input, {
476
+ identity,
477
+ missing: external[identity] === MISSING_INPUT_STATE,
478
+ });
330
479
  }
331
480
  }
332
481
  /**
@@ -370,29 +519,59 @@ function selectWatchInputs(props) {
370
519
  function deriveWatchInputs(state, props, fileIdentity) {
371
520
  const graph = envelopeGraphIndexes(state, props);
372
521
  const output = [];
373
- const seen = new Set();
522
+ const physicalSeen = new Set();
523
+ const lexicalSeen = new Set();
374
524
  const excluded = new Set([fileIdentity]);
375
525
  if (props.temporaryTsconfig !== undefined) {
376
526
  excluded.add(derivationIdentity(state, props.temporaryTsconfig));
377
527
  }
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;
528
+ const currentSpelling = path.resolve(props.file);
529
+ const temporarySpelling = props.temporaryTsconfig === undefined
530
+ ? undefined
531
+ : path.resolve(props.temporaryTsconfig);
532
+ const appendLexical = (input) => {
533
+ const spelling = path.resolve(input);
534
+ if (spelling === currentSpelling ||
535
+ spelling === temporarySpelling ||
536
+ lexicalSeen.has(spelling)) {
537
+ return;
390
538
  }
391
- seen.add(identity);
392
- output.push(absolute);
393
- }
539
+ lexicalSeen.add(spelling);
540
+ physicalSeen.add(derivationIdentity(state, input));
541
+ output.push(input);
542
+ };
543
+ const appendPhysical = (input) => {
544
+ const identity = derivationIdentity(state, input);
545
+ if (excluded.has(identity) || physicalSeen.has(identity))
546
+ return;
547
+ physicalSeen.add(identity);
548
+ lexicalSeen.add(path.resolve(input));
549
+ output.push(input);
550
+ };
551
+ for (const input of selectFileDependencies(props))
552
+ appendLexical(input);
553
+ for (const input of selectGraphInputs(graph, state, {
554
+ ...props,
555
+ complete: declaresCompleteDependencies(state, props) &&
556
+ !isVolatileFile(state, props),
557
+ }))
558
+ appendPhysical(input);
559
+ // Resolution candidates, plugin dependencies, and universal host inputs
560
+ // preserve lexical aliases. Physical deduplication would collapse
561
+ // `alias/selection.cjs` into the selected target path, so a bundler would
562
+ // watch only the target and miss a symlink/junction retarget.
563
+ for (const input of selectResolutionCandidateInputs(graph, state, props))
564
+ appendLexical(input);
565
+ for (const input of selectHostInputs(props))
566
+ appendLexical(input);
394
567
  return output;
395
568
  }
569
+ /** Return exact host-wide descriptor/config inputs for every output file. */
570
+ function selectHostInputs(props) {
571
+ return props.result.type === "exception"
572
+ ? []
573
+ : selectListedFiles(props.projectRoot, props.result.hostInputs);
574
+ }
396
575
  /**
397
576
  * Return the module-resolution paths that can supersede a currently resolved
398
577
  * module reachable from `file`. They remain host-owned even when a plugin
@@ -638,14 +817,13 @@ function createTransformResult(source, code) {
638
817
  *
639
818
  * Always compares the current module's in-memory source with the generation
640
819
  * 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.
820
+ * that comparison alone for a stable generation's first module delivery in the
821
+ * current build. An incomplete generation may not take this shortcut: otherwise
822
+ * a sibling output captured during a filesystem race could still be served
823
+ * once. Later graph-bearing requests validate the file's derived input set and
824
+ * project membership; graph-free envelopes conservatively re-hash the complete
825
+ * project and out-of-walk snapshots. Any mismatch forces a complete
826
+ * re-transform.
649
827
  */
650
828
  function matchesCachedSource(cached, file, source, buildScoped) {
651
829
  const identities = envelopeDerivation(cached).identityContext;
@@ -654,201 +832,1611 @@ function matchesCachedSource(cached, file, source, buildScoped) {
654
832
  return false;
655
833
  }
656
834
  if (buildScoped &&
835
+ cached.projectSnapshotComplete === true &&
657
836
  !cached.servedFiles?.has(pathIdentityKey(file, identities))) {
658
837
  return true;
659
838
  }
660
- const currentHashes = collectProjectInputHashes(cached.projectRoot, identities);
661
- currentHashes[currentKey] = hashText(source);
662
- if (!sameHashes(cached.inputHashes, currentHashes)) {
663
- return false;
839
+ if (cached.result.type !== "exception" &&
840
+ cached.result.graph !== undefined &&
841
+ cached.projectSnapshotComplete === true &&
842
+ cached.projectDirectories !== undefined &&
843
+ cached.projectMutationTracker !== undefined &&
844
+ cached.hostInputMutationTracker !== undefined) {
845
+ const narrow = matchesNarrowPersistentInputs(cached, file);
846
+ if (narrow !== undefined) {
847
+ return narrow;
848
+ }
849
+ // Notifications stopped proving membership after this generation was
850
+ // produced. Losing the proof is not evidence of a change, so fall through
851
+ // to the snapshot the entry still carries.
664
852
  }
665
- // Re-hash the out-of-walk inputs the compiler reported for this generation
666
- // over exactly the recorded key universe, so an edit to a `node_modules`
667
- // declaration or a monorepo sibling source invalidates the entry even in a
668
- // host that never clears the cache between builds. A new out-of-walk input
669
- // cannot appear without some recorded input changing first: a new reference
670
- // edge requires editing an in-walk source, and a new global or config file
671
- // requires a tsconfig or package manifest change, both of which the project
672
- // walk above already detects.
673
- const externalHashes = cached.externalInputHashes ?? {};
674
- return sameHashes(externalHashes, collectExternalInputHashes(cached.externalInputPaths ?? Object.keys(externalHashes)));
853
+ return matchesCompleteInputSnapshot(cached, currentKey, source);
675
854
  }
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));
855
+ /**
856
+ * Validate one graph-bearing cached output against only the inputs that can
857
+ * affect that file. Project membership is validated once per event-loop turn,
858
+ * so sibling module deliveries share one directory-metadata pass instead of
859
+ * multiplying it by module count.
860
+ *
861
+ * Returns `undefined` when this narrow proof is unavailable — live
862
+ * notifications can no longer prove membership, or the generation carries no
863
+ * universal-input manifest. That is the absence of a proof, not evidence of a
864
+ * change, so the caller falls back to complete-snapshot validation instead of
865
+ * discarding the generation. A reported membership event, a changed universal
866
+ * input, or a changed derived input is evidence, and returns `false`.
867
+ */
868
+ function matchesNarrowPersistentInputs(cached, file) {
869
+ if (reportsMembershipChange(cached)) {
870
+ return false;
871
+ }
872
+ if (!notificationsProveMembership(cached)) {
873
+ return undefined;
874
+ }
875
+ const state = envelopeDerivation(cached);
876
+ const hostValidation = cached.hostInputValidation;
877
+ if (hostValidation === undefined) {
878
+ return undefined;
879
+ }
880
+ if (!matchesUniversalHostInputs(cached, hostValidation)) {
881
+ return false;
882
+ }
883
+ const inputs = selectWatchInputs({
884
+ file,
885
+ projectRoot: cached.projectRoot,
886
+ result: cached.result,
887
+ temporaryTsconfig: cached.temporaryTsconfig,
888
+ });
889
+ for (const input of inputs) {
890
+ // Skip by spelling, not identity: the manifest proved this exact path, and
891
+ // an alias of the same physical file is a different input whose own
892
+ // retarget nothing else would see.
893
+ if (hostValidation.covered.has(path.resolve(input))) {
894
+ continue;
895
+ }
896
+ if (!matchesProvenInput(cached, state, input)) {
897
+ return false;
898
+ }
899
+ }
900
+ return true;
679
901
  }
680
902
  /**
681
- * Build the input-hash snapshot stored alongside a fresh compiler result.
903
+ * Validate one derived input against the generation, skipping the content read
904
+ * while the recorded metadata signature still holds.
682
905
  *
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.
906
+ * Sibling deliveries of one generation share most of their derived inputs, and
907
+ * `graph.globals` is shared by every one of them, so re-reading and re-hashing
908
+ * the whole derived set per delivery multiplies one generation's proven bytes
909
+ * by the module count. The derived set is proven the same way the universal
910
+ * descriptor inputs are ({@link matchesUniversalHostInputs}), under the same
911
+ * rules: an unchanged signature stands in for the content comparison, and any
912
+ * signature change falls back to the full comparison. A signature is recorded
913
+ * only around a read nothing raced, only for a recorded state that came from
914
+ * reading the input rather than from failing to, and only while the observed
915
+ * filesystem's own clock has provably left the stamp's tick
916
+ * ({@link stampSeparable}), so a same-length rewrite inside that tick cannot
917
+ * hide behind an unchanged signature.
687
918
  *
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.
692
- */
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;
919
+ * The signature carries the physical identity of both the lexical path and its
920
+ * link target ({@link inputMetadataSignature}), so retargeting a symlink or
921
+ * junction moves it and the skipped realpath comparison cannot be evaded.
922
+ */
923
+ function matchesProvenInput(cached, state, input) {
924
+ const slot = inputSignatureSlot(cached, state, input);
925
+ if (slot === undefined) {
926
+ return matchesRecordedInput(cached, input);
927
+ }
928
+ if (slot.recorded === MISSING_INPUT_STATE && notifiesAbsence(cached, input)) {
929
+ // The generation's watcher holds this exact name, and the caller already
930
+ // established that neither tracker failed and neither reported a change.
931
+ // The path is therefore still absent, proven by the same channel that
932
+ // proves project membership, and probing it again would only repeat what
933
+ // the notification already answered.
934
+ return true;
935
+ }
936
+ const filesystem = resultFilesystem(cached.result);
937
+ const before = inputMetadataEvidence(input, filesystem);
938
+ if (before !== undefined && slot.signatures[slot.key] === before.signature) {
939
+ return true;
940
+ }
941
+ if (!matchesRecordedInput(cached, input)) {
942
+ return false;
943
+ }
944
+ // A recorded `missing` state is the one comparison that succeeds without
945
+ // reading anything: an unreadable path still reports `missing`, so its
946
+ // metadata can hold still while the bytes behind it appear. Only content a
947
+ // read produced may be stood for.
948
+ const after = slot.recorded === MISSING_INPUT_STATE
949
+ ? undefined
950
+ : inputMetadataSignature(input, filesystem);
951
+ if (after !== undefined && before?.signature === after && before.separable) {
952
+ slot.signatures[slot.key] = after;
953
+ }
954
+ else {
955
+ delete slot.signatures[slot.key];
956
+ }
957
+ return true;
700
958
  }
701
959
  /**
702
- * Hash every input file under `projectRoot` (the same walk universe
703
- * {@link matchesCachedSource} validates against), keyed by project-relative
704
- * slash path. Exported so hosts without a per-build boundary (`@ttsc/metro`)
705
- * can fold the identical input universe into their own cache fingerprints.
960
+ * Report whether the generation's live watcher would announce a creation at
961
+ * this absent input's exact spelling.
962
+ *
963
+ * Losing the watcher is not evidence of anything, so a failed tracker sends the
964
+ * input back to being probed by hand, exactly as a failed tracker already sends
965
+ * the whole generation back to complete-snapshot validation.
706
966
  */
707
- function collectProjectInputHashes(projectRoot, identities = createHostPathIdentityContext()) {
708
- const hashes = {};
709
- for (const file of listProjectInputFiles(projectRoot)) {
710
- try {
711
- hashes[toProjectKey(projectRoot, file, identities)] = hashText(fs.readFileSync(file));
967
+ function notifiesAbsence(cached, input) {
968
+ const tracker = cached.candidateMutationTracker;
969
+ return (tracker !== undefined &&
970
+ !tracker.failed &&
971
+ tracker.covered?.has(path.resolve(input)) === true);
972
+ }
973
+ /**
974
+ * Locate the signature manifest that owns one recorded input, mirroring
975
+ * {@link matchesRecordedInput}'s own preference for the out-of-walk spelling's
976
+ * snapshot over the walked project's.
977
+ *
978
+ * The manifest is returned whether or not it currently holds a signature for
979
+ * the input, so a content comparison that succeeds can record one. Without
980
+ * that, an input whose capture-time metadata was too recent to prove anything
981
+ * would keep its content read for the whole life of the generation, since
982
+ * nothing else ever revisits it. Returns `undefined` only for an input the
983
+ * generation recorded no hash for, which no signature could stand for.
984
+ */
985
+ function inputSignatureSlot(cached, state, input) {
986
+ const identity = derivationIdentity(state, input);
987
+ const external = cached.externalInputHashes ?? {};
988
+ if (Object.prototype.hasOwnProperty.call(external, identity)) {
989
+ // The recorded hash is identity-keyed because aliases of one physical file
990
+ // share its content; the signature is spelling-keyed because they do not
991
+ // share its metadata.
992
+ return {
993
+ key: path.resolve(input),
994
+ recorded: external[identity],
995
+ signatures: (cached.externalInputSignatures ??= {}),
996
+ };
997
+ }
998
+ const projectKey = toProjectKey(cached.projectRoot, input, state.identityContext);
999
+ return Object.prototype.hasOwnProperty.call(cached.inputHashes, projectKey)
1000
+ ? {
1001
+ key: projectKey,
1002
+ recorded: cached.inputHashes[projectKey],
1003
+ signatures: (cached.inputSignatures ??= {}),
712
1004
  }
713
- catch {
714
- // File watchers may observe a transform while another process is moving
715
- // or deleting files. The missing key invalidates older cache entries.
1005
+ : undefined;
1006
+ }
1007
+ /**
1008
+ * Validate universal descriptor/config inputs without re-reading them for every
1009
+ * module. Existing paths use the same nanosecond metadata manifest that guards
1010
+ * GOROOT identity memoization; missing probes are grouped by the nearest
1011
+ * existing directory and checked through one exact membership listing.
1012
+ */
1013
+ function matchesUniversalHostInputs(cached, validation) {
1014
+ return (matchesUniversalHostInputEntries(cached, validation) &&
1015
+ matchesUniversalHostInputProbes(cached, validation));
1016
+ }
1017
+ /**
1018
+ * Validate the universal inputs that exist, by metadata first and content only
1019
+ * when that moved.
1020
+ *
1021
+ * Every rejection here is evidence of a change — a vanished path, a moved
1022
+ * physical target, a strict blocker's metadata, differing content — so this
1023
+ * half is safe for a validation path that must never discard a generation for
1024
+ * want of a proof.
1025
+ */
1026
+ function matchesUniversalHostInputEntries(cached, validation) {
1027
+ const filesystem = resultFilesystem(cached.result);
1028
+ for (const entry of validation.entries.values()) {
1029
+ const evidence = inputMetadataEvidence(entry.path, filesystem);
1030
+ if (entry.signature !== undefined &&
1031
+ evidence?.signature === entry.signature)
1032
+ continue;
1033
+ if (entry.strict === true)
1034
+ return false;
1035
+ if (hostInputRealpath(entry.path, filesystem) !== entry.realpath)
1036
+ return false;
1037
+ if (!matchesRecordedInput(cached, entry.path)) {
1038
+ return false;
716
1039
  }
1040
+ if (evidence === undefined)
1041
+ return false;
1042
+ // Re-earn the proof under the rules the capture applies: an entry whose
1043
+ // recorded state came from reading nothing keeps its content comparison, a
1044
+ // write racing the read that just proved it records nothing, and a stamp
1045
+ // the filesystem's clock has not provably left records nothing either.
1046
+ const after = inputMetadataSignature(entry.path, filesystem);
1047
+ entry.signature =
1048
+ entry.readable && evidence.separable && after === evidence.signature
1049
+ ? evidence.signature
1050
+ : undefined;
717
1051
  }
718
- return hashes;
1052
+ return true;
719
1053
  }
720
1054
  /**
721
- * Enumerate every regular file under `root`, skipping well-known output and
722
- * tooling directories (see {@link isIgnoredProjectDirectory}).
1055
+ * Prove the universal inputs that were absent are still absent, through one
1056
+ * exact listing of the nearest directory that can settle it.
723
1057
  *
724
- * Uses an iterative DFS instead of `fs.readdirSync` recursion to avoid
725
- * unbounded call-stack depth on deep project trees. The result is sorted so
726
- * that hash comparisons are deterministic across OS-level directory orderings.
1058
+ * Unlike the entries half, this one rejects on an inability to prove: a
1059
+ * directory that exists but cannot be listed certifies nothing about the
1060
+ * candidates inside it. That is the right answer for the narrow path, which has
1061
+ * no stronger proof to fall back to, but not for the whole-snapshot path, where
1062
+ * the recorded `missing` markers are re-compared directly and losing a proof
1063
+ * must not cost the cache.
727
1064
  */
728
- function listProjectInputFiles(root) {
729
- const out = [];
730
- const stack = [root];
731
- while (stack.length !== 0) {
732
- const current = stack.pop();
1065
+ function matchesUniversalHostInputProbes(cached, validation) {
1066
+ const filesystem = resultFilesystem(cached.result);
1067
+ for (const [directory, names] of validation.missing) {
733
1068
  let entries;
734
1069
  try {
735
- entries = fs.readdirSync(current, { withFileTypes: true });
736
- }
737
- catch {
738
- continue;
1070
+ entries = filesystem.readdir(directory);
739
1071
  }
740
- for (const entry of entries) {
741
- if (isIgnoredProjectDirectory(entry.name)) {
1072
+ catch (error) {
1073
+ // Only a provably absent/non-directory ancestor keeps every descendant
1074
+ // unreachable. Permission and transient I/O failures cannot prove that
1075
+ // a candidate is still missing, while replacing the proving directory
1076
+ // with an exact file can itself redirect module resolution.
1077
+ try {
1078
+ if (!filesystem.stat(directory).isDirectory())
1079
+ return false;
1080
+ }
1081
+ catch (statError) {
1082
+ if (!isMissingPathError(statError))
1083
+ return false;
742
1084
  continue;
743
1085
  }
744
- const file = path.join(current, entry.name);
745
- if (entry.isDirectory()) {
746
- stack.push(file);
1086
+ return false;
1087
+ }
1088
+ const identities = envelopeDerivation(cached).identityContext;
1089
+ const caseSensitive = identities.caseSensitive(directory);
1090
+ if (entries.some((entry) => names.has(normalizeHostInputName(entry.name, caseSensitive)))) {
1091
+ return false;
1092
+ }
1093
+ }
1094
+ return true;
1095
+ }
1096
+ /** True only for errors that prove a path cannot currently be traversed. */
1097
+ function isMissingPathError(error) {
1098
+ const code = error?.code;
1099
+ return code === "ENOENT" || code === "ENOTDIR";
1100
+ }
1101
+ /** Capture the universal-input manifest while the generation is still fresh. */
1102
+ function captureUniversalHostInputValidation(cached, currentFile) {
1103
+ const filesystem = resultFilesystem(cached.result);
1104
+ const state = envelopeDerivation(cached);
1105
+ const validation = {
1106
+ entries: new Map(),
1107
+ covered: new Set(),
1108
+ missing: new Map(),
1109
+ };
1110
+ for (const input of selectPersistentHostInputs({
1111
+ filesystem,
1112
+ projectRoot: cached.projectRoot,
1113
+ result: cached.result,
1114
+ temporaryTsconfig: cached.temporaryTsconfig,
1115
+ })) {
1116
+ const generationHashes = cached.result.type === "exception"
1117
+ ? undefined
1118
+ : cached.result.hostInputHashes;
1119
+ const generationRealpaths = cached.result.type === "exception"
1120
+ ? undefined
1121
+ : cached.result.hostInputRealpaths;
1122
+ const expected = generationHashes?.[path.resolve(input)];
1123
+ // Every persistent universal input must carry an evaluation-time
1124
+ // fingerprint. If a plugin/native host cannot provide one, keep the fresh
1125
+ // result but decline narrow long-lived reuse.
1126
+ let readable = false;
1127
+ if (expected === undefined) {
1128
+ const current = path.resolve(currentFile);
1129
+ if (path.resolve(input) !== current)
1130
+ return undefined;
1131
+ // The current module may be supplied from an unsaved editor buffer. Its
1132
+ // generation snapshot is overlaid below from `currentSource`, so a disk
1133
+ // fingerprint would be both unavailable and the wrong authority. The
1134
+ // recorded state is the bundler's, so a signature of the disk cannot
1135
+ // stand for it however readable that disk is.
1136
+ }
1137
+ else {
1138
+ const current = hostInputStateHash(input, filesystem);
1139
+ if (expected !== current) {
1140
+ return undefined;
747
1141
  }
748
- else if (entry.isFile()) {
749
- out.push(file);
1142
+ // A path both sides agree they could not read carries no bytes for a
1143
+ // signature to stand for. It still belongs in the manifest, so the
1144
+ // content comparison keeps running for it on every delivery.
1145
+ readable = current !== null;
1146
+ }
1147
+ const absoluteInput = path.resolve(input);
1148
+ if (generationRealpaths !== undefined) {
1149
+ if (!Object.prototype.hasOwnProperty.call(generationRealpaths, absoluteInput) ||
1150
+ !sameHostInputRealpath(generationRealpaths[absoluteInput], hostInputRealpath(input, filesystem), state.identityContext)) {
1151
+ return undefined;
750
1152
  }
751
1153
  }
1154
+ validation.covered.add(path.resolve(input));
1155
+ const before = inputMetadataEvidence(input, filesystem);
1156
+ if (!matchesRecordedInput(cached, input))
1157
+ return undefined;
1158
+ const after = inputMetadataSignature(input, filesystem);
1159
+ if (before?.signature !== after)
1160
+ return undefined;
1161
+ if (before !== undefined) {
1162
+ // Do not key this manifest by physical identity. A symlink/junction
1163
+ // spelling and its selected target deliberately share that identity,
1164
+ // but both lexical paths must survive so retargeting the alias is visible.
1165
+ validation.entries.set(path.resolve(input), {
1166
+ path: input,
1167
+ readable,
1168
+ realpath: hostInputRealpath(input, filesystem),
1169
+ // The signature stands in for content only when the read produced the
1170
+ // recorded bytes and the filesystem's clock has provably left the
1171
+ // stamp's tick; otherwise the content comparison keeps running until
1172
+ // the re-earn path can prove both.
1173
+ signature: readable && before.separable ? before.signature : undefined,
1174
+ });
1175
+ continue;
1176
+ }
1177
+ const probe = missingPathProbe(input, filesystem);
1178
+ if (probe.blocker !== undefined) {
1179
+ const signature = inputMetadataSignature(probe.blocker, filesystem);
1180
+ if (signature === undefined)
1181
+ return undefined;
1182
+ // A blocker proves a kind and an identity, not content: it is the
1183
+ // non-directory ancestor that makes everything below it unreachable, and
1184
+ // it cannot stop being that without its metadata moving. So it keeps a
1185
+ // usable signature whether or not anything read it, and exempt from the
1186
+ // clock-separability rule content signatures need — a same-tick rewrite
1187
+ // of its bytes leaves it exactly as blocking as before.
1188
+ validation.covered.add(path.resolve(probe.blocker));
1189
+ validation.entries.set(path.resolve(probe.blocker), {
1190
+ path: probe.blocker,
1191
+ readable: true,
1192
+ realpath: hostInputRealpath(probe.blocker, filesystem),
1193
+ signature,
1194
+ strict: true,
1195
+ });
1196
+ continue;
1197
+ }
1198
+ // The probe below proves this exact spelling absent, so the per-module loop
1199
+ // need not re-derive it either.
1200
+ let names = validation.missing.get(probe.directory);
1201
+ if (names === undefined) {
1202
+ names = new Set();
1203
+ validation.missing.set(probe.directory, names);
1204
+ }
1205
+ names.add(normalizeHostInputName(probe.name, state.identityContext.caseSensitive(probe.directory)));
752
1206
  }
753
- out.sort();
754
- return out;
1207
+ cached.hostInputValidation = validation;
1208
+ return validation;
755
1209
  }
756
1210
  /**
757
- * Report whether an absolute `file` belongs to the project walk universe of
758
- * `root`: it lies under `root`, every component exists without traversing a
759
- * 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}".
762
- * Missing paths and files reached through symlinks or Windows junctions are
763
- * out-of-walk inputs that only the reference graph can prove relevant.
1211
+ * The recorded state of an input the generation read nothing from: absent, or
1212
+ * present but unreadable. It is deliberately not a hash, so no signature may
1213
+ * stand in for it: the metadata of an unreadable path holds still while the
1214
+ * bytes behind it appear.
1215
+ *
1216
+ * A directory is not this state. It records the hash of a marker instead, which
1217
+ * a signature may stand for, because the mode both halves of the signature
1218
+ * carry cannot change without the path ceasing to be that directory.
764
1219
  */
765
- function isProjectWalkPath(root, file, identities = createHostPathIdentityContext()) {
766
- if (!identities.isWithin(root, file)) {
767
- return false;
1220
+ const MISSING_INPUT_STATE = "missing";
1221
+ /**
1222
+ * The highest stamp each observed filesystem clock has provably minted, keyed
1223
+ * by the operations object that observes it and, inside, by reporting device.
1224
+ *
1225
+ * A filesystem stamps a write once per clock tick, so two same-length writes
1226
+ * inside one tick are indistinguishable by metadata alone. A signature may
1227
+ * therefore stand for content only while a later write is guaranteed to move
1228
+ * it, and that guarantee needs a reference instant the observed filesystem
1229
+ * itself produced: once some stamp on the same device is strictly newer than an
1230
+ * input's modification stamp, that input's tick is provably over, so any later
1231
+ * write must mint a newer stamp and move the signature. That is git's
1232
+ * racily-clean index rule, adapted to a read-only contract: where git compares
1233
+ * entries against the index file's own timestamp, this floor accumulates every
1234
+ * stamp the cache-owned operations report, seeded per generation by
1235
+ * {@link mintFilesystemClockReference}.
1236
+ *
1237
+ * The process clock never participates: both sides of every comparison are
1238
+ * stamps the same filesystem clock minted, at the same granularity, so a
1239
+ * filesystem clock running behind (or ahead of) the host process changes
1240
+ * nothing.
1241
+ *
1242
+ * Accumulating observed stamps is deliberately weaker than git's own reference,
1243
+ * which is a single stamp git minted itself. A stamp this floor accepts may
1244
+ * instead have been _set_ rather than minted, and a set stamp is dangerous only
1245
+ * when it lands in the future: the floor is a maximum, so a restored past stamp
1246
+ * never raises it. One future-dated file — a stamp-preserving extraction or
1247
+ * copy from a machine whose clock ran ahead — pushes its device's floor past
1248
+ * the present and reopens the same-tick window for every other input on that
1249
+ * device until the clock catches up. A clock that jumps backwards strands the
1250
+ * floor above the present the same way, a different hazard from the constant
1251
+ * offset the paragraph above is about: an offset moves both operands together
1252
+ * and changes nothing, a jump moves only the present.
1253
+ *
1254
+ * The minted probe is not enough on its own to replace observed stamps: it
1255
+ * lands on the scratch volume, which is frequently not the inputs' volume (a
1256
+ * project on `D:` with `TEMP` on `C:`), and a probe-only floor would then
1257
+ * decline every _content_ signature, so every input carrying bytes would be
1258
+ * re-read on every delivery. A strict blocker keeps its signature either way,
1259
+ * because it proves a kind rather than content. Observed stamps keep the common
1260
+ * case working; the probe covers the case they cannot, a tree whose files were
1261
+ * all written inside one tick.
1262
+ */
1263
+ const FILESYSTEM_CLOCK_FLOORS = new WeakMap();
1264
+ /** Return one observed filesystem's per-device clock floor, creating it. */
1265
+ function filesystemClockFloors(filesystem) {
1266
+ let floors = FILESYSTEM_CLOCK_FLOORS.get(filesystem);
1267
+ if (floors === undefined) {
1268
+ floors = new Map();
1269
+ FILESYSTEM_CLOCK_FLOORS.set(filesystem, floors);
1270
+ }
1271
+ return floors;
1272
+ }
1273
+ /** Raise a device's clock floor with the stamps one observation reported. */
1274
+ function observeFilesystemClock(filesystem, stats) {
1275
+ const floors = filesystemClockFloors(filesystem);
1276
+ const stamp = stats.mtimeNs > stats.ctimeNs ? stats.mtimeNs : stats.ctimeNs;
1277
+ const current = floors.get(stats.dev);
1278
+ if (current === undefined || stamp > current) {
1279
+ floors.set(stats.dev, stamp);
768
1280
  }
769
- const rootKey = pathIdentityKey(root, identities);
770
- const fileKey = pathIdentityKey(file, identities);
771
- const relative = fileKey.slice(rootKey.length).replace(/^[/\\]+/, "");
772
- if (relative.length === 0 ||
773
- relative === ".." ||
774
- relative.startsWith(`..${path.sep}`) ||
775
- path.isAbsolute(relative)) {
776
- return false;
1281
+ }
1282
+ /**
1283
+ * Report whether a later write to the observed path is guaranteed to move its
1284
+ * modification stamp: the device's clock floor holds a stamp strictly newer, so
1285
+ * the tick that minted the stamp is provably over. The floor was observed
1286
+ * before the caller's content read began, which is the ordering the guarantee
1287
+ * needs — a stamp minted before the read proves every post-read write lands in
1288
+ * a newer tick.
1289
+ */
1290
+ function stampSeparable(filesystem, stats) {
1291
+ const floor = filesystemClockFloors(filesystem).get(stats.dev);
1292
+ return floor !== undefined && stats.mtimeNs < floor;
1293
+ }
1294
+ /**
1295
+ * Mint a reference instant for this generation and feed it into the observed
1296
+ * filesystem's clock floor.
1297
+ *
1298
+ * The scratch directory is a write the adapter already owns, deliberately
1299
+ * outside the project root, so stamping a probe file there produces a
1300
+ * freshly-minted "now" without touching the user's project — the analogue of
1301
+ * git writing its index. The probe is observed through the cache-owned
1302
+ * operations and keyed by the device those operations report, so it only ever
1303
+ * separates stamps on the filesystem that actually minted it; when the scratch
1304
+ * volume differs from the inputs' volume, or the observed filesystem cannot see
1305
+ * the probe at all, nothing is proven and signature recording simply stays
1306
+ * declined until passively observed stamps separate an input on their own.
1307
+ *
1308
+ * Relocating the scratch directory onto the inputs' volume would make the probe
1309
+ * universal, but it would also move every compiler and plugin temporary write
1310
+ * into the project's parent (frequently a monorepo root or a home directory)
1311
+ * for those layouts. That is a product decision about where ttsc writes, not a
1312
+ * property of this rule, so the cross-volume case degrades to more reads here
1313
+ * rather than being bought with it.
1314
+ */
1315
+ function mintFilesystemClockReference(scratchDirectory, filesystem) {
1316
+ try {
1317
+ const probe = path.join(scratchDirectory, "clock-reference");
1318
+ fs.writeFileSync(probe, "");
1319
+ observeFilesystemClock(filesystem, filesystem.lstat(probe));
777
1320
  }
778
- const segments = relative.split(path.sep);
779
- if (segments.some(isIgnoredProjectDirectory)) {
780
- return false;
1321
+ catch {
1322
+ // The absence of a reference declines signature recording; it never
1323
+ // invalidates a generation.
781
1324
  }
782
- let current = path.resolve(root);
783
- for (let index = 0; index < segments.length; ++index) {
784
- current = path.join(current, segments[index]);
785
- let stats;
1325
+ }
1326
+ /** Metadata identity whose stability lets a generation reuse a content hash. */
1327
+ function inputMetadataSignature(file, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1328
+ return inputMetadataEvidence(file, filesystem)?.signature;
1329
+ }
1330
+ /** Observe one input's metadata signature and its clock separability. */
1331
+ function inputMetadataEvidence(file, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1332
+ try {
1333
+ const link = filesystem.lstat(file);
1334
+ observeFilesystemClock(filesystem, link);
1335
+ let target = link;
1336
+ if (link.isSymbolicLink()) {
1337
+ try {
1338
+ target = filesystem.statBigInt(file);
1339
+ observeFilesystemClock(filesystem, target);
1340
+ }
1341
+ catch {
1342
+ // Keep a broken link in the existing-input manifest. Its own metadata
1343
+ // stays stable while the target is missing, and the first successful
1344
+ // stat after the target appears changes this signature. Treating it as
1345
+ // a plain missing path would watch/list only the link's parent, which
1346
+ // cannot observe a target created in another directory. It carries no
1347
+ // readable bytes, so it never needs to be separable.
1348
+ return {
1349
+ signature: [
1350
+ link.dev,
1351
+ link.ino,
1352
+ link.mode,
1353
+ link.size,
1354
+ link.mtimeNs,
1355
+ link.ctimeNs,
1356
+ "missing-target",
1357
+ ].join(":"),
1358
+ separable: false,
1359
+ };
1360
+ }
1361
+ }
1362
+ return {
1363
+ signature: [
1364
+ link.dev,
1365
+ link.ino,
1366
+ link.mode,
1367
+ link.size,
1368
+ link.mtimeNs,
1369
+ link.ctimeNs,
1370
+ target.dev,
1371
+ target.ino,
1372
+ target.mode,
1373
+ target.size,
1374
+ target.mtimeNs,
1375
+ target.ctimeNs,
1376
+ ].join(":"),
1377
+ // Both halves must be separable: a write remints the target's stamp, a
1378
+ // link retarget the link's own, and either one hiding inside its recorded
1379
+ // tick would evade the skipped content and realpath comparisons.
1380
+ separable: stampSeparable(filesystem, link) && stampSeparable(filesystem, target),
1381
+ };
1382
+ }
1383
+ catch {
1384
+ return undefined;
1385
+ }
1386
+ }
1387
+ /** Content/kind fingerprint matching the compiler host-input contract. */
1388
+ function hostInputStateHash(file, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1389
+ try {
1390
+ return hashText(filesystem.readFile(file));
1391
+ }
1392
+ catch {
786
1393
  try {
787
- stats = fs.lstatSync(current);
1394
+ return filesystem.stat(file).isDirectory()
1395
+ ? hashText("ttsc:host-input:directory\0")
1396
+ : null;
788
1397
  }
789
1398
  catch {
790
- return false;
791
- }
792
- if (stats.isSymbolicLink()) {
793
- return false;
794
- }
795
- const leaf = index === segments.length - 1;
796
- if ((leaf && !stats.isFile()) || (!leaf && !stats.isDirectory())) {
797
- return false;
1399
+ return null;
798
1400
  }
799
1401
  }
800
- return true;
801
1402
  }
802
- /**
803
- * 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.
810
- */
811
- function collectExternalInputHashes(paths) {
812
- const hashes = {};
813
- const identities = createHostPathIdentityContext();
814
- for (const file of paths) {
815
- const identity = pathIdentityKey(file, identities);
816
- if (identity in hashes) {
817
- continue;
1403
+ /** Fingerprint the text/kind state returned by TypeScript-Go's filesystem. */
1404
+ function graphInputStateHash(file, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1405
+ try {
1406
+ const bytes = filesystem.readFile(file);
1407
+ if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
1408
+ const even = bytes.subarray(2, 2 + Math.floor((bytes.length - 2) / 2) * 2);
1409
+ return hashText(Buffer.from(even.toString("utf16le"), "utf8"));
1410
+ }
1411
+ if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
1412
+ const even = Buffer.from(bytes.subarray(2, 2 + Math.floor((bytes.length - 2) / 2) * 2));
1413
+ even.swap16();
1414
+ return hashText(Buffer.from(even.toString("utf16le"), "utf8"));
818
1415
  }
1416
+ const content = bytes.length >= 3 &&
1417
+ bytes[0] === 0xef &&
1418
+ bytes[1] === 0xbb &&
1419
+ bytes[2] === 0xbf
1420
+ ? bytes.subarray(3)
1421
+ : bytes;
1422
+ return hashText(content);
1423
+ }
1424
+ catch {
819
1425
  try {
820
- hashes[identity] = hashText(fs.readFileSync(file));
1426
+ return filesystem.stat(file).isDirectory()
1427
+ ? hashText("ttsc:host-input:directory\0")
1428
+ : null;
821
1429
  }
822
1430
  catch {
823
- hashes[identity] = "missing";
1431
+ return null;
824
1432
  }
825
1433
  }
826
- return hashes;
1434
+ }
1435
+ /** Physical target selected by a lexical host-input path. */
1436
+ function hostInputRealpath(file, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1437
+ try {
1438
+ return filesystem.realpath(file);
1439
+ }
1440
+ catch {
1441
+ return null;
1442
+ }
1443
+ }
1444
+ /** Compare two reported realpaths by filesystem identity, not Windows spelling. */
1445
+ function sameHostInputRealpath(left, right, identities) {
1446
+ if (left === undefined || (left === null) !== (right === null))
1447
+ return false;
1448
+ if (left === null || right === null)
1449
+ return true;
1450
+ return (pathIdentityKey(left, identities) === pathIdentityKey(right, identities));
1451
+ }
1452
+ /** Find one directory listing that proves an absent path is still absent. */
1453
+ function missingPathProbe(file, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1454
+ let child = path.resolve(file);
1455
+ for (;;) {
1456
+ const directory = path.dirname(child);
1457
+ try {
1458
+ const stats = filesystem.stat(directory);
1459
+ if (stats.isDirectory()) {
1460
+ return { directory, name: path.basename(child) };
1461
+ }
1462
+ return {
1463
+ blocker: directory,
1464
+ directory: path.dirname(directory),
1465
+ name: path.basename(directory),
1466
+ };
1467
+ }
1468
+ catch { }
1469
+ if (directory === child) {
1470
+ return { directory, name: path.basename(child) };
1471
+ }
1472
+ child = directory;
1473
+ }
827
1474
  }
828
1475
  /**
829
- * Derive the absolute out-of-walk input set of a whole project transform: the
830
- * union of every reference-graph member (edge keys and targets, globals, the
831
- * config chain) and every plugin-reported dependency, minus everything the
832
- * project walk already hashes and the disposed temp-dir tsconfig. These are the
833
- * inputs {@link matchesCachedSource}'s walk cannot see. Resolution candidates
834
- * that are still missing remain in this set even under the project root: the
835
- * first walk cannot hash a file that has not been created yet.
1476
+ * Prove one generation from its own recorded snapshot, with no help from live
1477
+ * notifications.
836
1478
  *
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.
1479
+ * This is the fallback for a graph-free envelope and for a generation whose
1480
+ * watchers could not be opened or have since failed: losing the notification
1481
+ * proof must cost the narrow path, not the cache. The walk re-proves membership
1482
+ * directly the recorded directory signatures plus the recorded file-key
1483
+ * universe so a created, deleted, or renamed input still invalidates without
1484
+ * any watcher.
845
1485
  */
846
- function selectExternalInputPaths(props) {
847
- if (props.result.type === "exception") {
848
- return [];
1486
+ function matchesCompleteInputSnapshot(cached, currentKey, source) {
1487
+ if (cached.projectSnapshotComplete !== true ||
1488
+ cached.projectDirectories === undefined) {
1489
+ return false;
1490
+ }
1491
+ // Universal descriptor/config inputs carry a physical-identity proof that no
1492
+ // content comparison can replace: retargeting a symlinked input to a
1493
+ // byte-identical file selects a different file, and its own transitive
1494
+ // requires with it. Only the graph half of the out-of-walk snapshot records
1495
+ // realpaths, so without this the fallback would quietly hold a lower standard
1496
+ // than the narrow path it stands in for.
1497
+ const state = envelopeDerivation(cached);
1498
+ const hostValidation = cached.hostInputValidation;
1499
+ if (hostValidation === undefined ||
1500
+ !matchesUniversalHostInputEntries(cached, hostValidation)) {
1501
+ return false;
1502
+ }
1503
+ const declaredInputs = declaredProjectInputKeys(state, cached);
1504
+ const current = collectProjectInputSnapshot(cached.projectRoot, state.identityContext, resultFilesystem(cached.result), cached.inputSignatures === undefined
1505
+ ? undefined
1506
+ : { hashes: cached.inputHashes, signatures: cached.inputSignatures });
1507
+ if (!walkSnapshotComplete(current, declaredInputs)) {
1508
+ return false;
1509
+ }
1510
+ if (!sameProjectDirectories(cached.projectDirectories, current.projectDirectories)) {
1511
+ return false;
1512
+ }
1513
+ current.hashes[currentKey] = hashText(source);
1514
+ if (!sameHashes(cached.inputHashes, current.hashes, declaredInputs)) {
1515
+ return false;
1516
+ }
1517
+ // Re-hash the out-of-walk inputs the compiler reported for this generation
1518
+ // over exactly the recorded key universe, so an edit to a `node_modules`
1519
+ // declaration or a monorepo sibling source invalidates the entry even in a
1520
+ // host that never clears the cache between builds. A new out-of-walk input
1521
+ // cannot appear without some recorded input changing first: a new reference
1522
+ // edge requires editing an in-walk source, and a new global or config file
1523
+ // requires a tsconfig or package manifest change, both of which the project
1524
+ // walk above already detects.
1525
+ const externalCurrent = matchesCachedExternalInputs(cached);
1526
+ if (!externalCurrent.matches || !matchesExternalInputRealpaths(cached)) {
1527
+ return false;
1528
+ }
1529
+ adoptProvenSignatures(cached, {
1530
+ currentKey,
1531
+ external: externalCurrent.signatures,
1532
+ project: current.provenSignatures,
1533
+ });
1534
+ return true;
1535
+ }
1536
+ /**
1537
+ * Adopt the signatures captured while this walk proved every recorded input
1538
+ * still carries its recorded content.
1539
+ *
1540
+ * Without this, a metadata-only change — a touch, or a rewrite of identical
1541
+ * bytes — costs a re-read on every later delivery for the rest of the
1542
+ * generation's life, because the recorded signature can never match again. The
1543
+ * narrow path self-heals through {@link matchesProvenInput}; this is the same
1544
+ * refresh for the path that proves the whole snapshot at once.
1545
+ *
1546
+ * The delivered file is the single exclusion: its recorded hash is the source
1547
+ * the bundler supplied, so the disk bytes this walk read for it were compared
1548
+ * against nothing.
1549
+ */
1550
+ function adoptProvenSignatures(cached, proven) {
1551
+ const projectSignatures = (cached.inputSignatures ??= {});
1552
+ for (const [key, signature] of Object.entries(proven.project)) {
1553
+ if (key === proven.currentKey)
1554
+ continue;
1555
+ projectSignatures[key] = signature;
1556
+ }
1557
+ const externalSignatures = (cached.externalInputSignatures ??= {});
1558
+ for (const [spelling, signature] of Object.entries(proven.external)) {
1559
+ externalSignatures[spelling] = signature;
1560
+ }
1561
+ }
1562
+ /** Re-check graph-owned physical identities in complete-snapshot fallback. */
1563
+ function matchesExternalInputRealpaths(cached) {
1564
+ const expected = cached.externalInputRealpaths;
1565
+ if (expected === undefined || Object.keys(expected).length === 0)
1566
+ return true;
1567
+ const state = envelopeDerivation(cached);
1568
+ const filesystem = resultFilesystem(cached.result);
1569
+ for (const input of cached.externalInputPaths ?? []) {
1570
+ const identity = derivationIdentity(state, input);
1571
+ if (!Object.prototype.hasOwnProperty.call(expected, identity))
1572
+ continue;
1573
+ if (!sameHostInputRealpath(expected[identity], hostInputRealpath(input, filesystem), state.identityContext)) {
1574
+ return false;
1575
+ }
1576
+ }
1577
+ return true;
1578
+ }
1579
+ /**
1580
+ * Capture external-input hashes without attaching post-compile state to an
1581
+ * earlier graph. Graph members must carry compiler-time proof and still match
1582
+ * it now; plugin-declared dependency-only paths retain the historical
1583
+ * post-compile snapshot because their own protocol does not claim generation
1584
+ * fingerprints.
1585
+ */
1586
+ function captureExternalInputSnapshot(cached, paths) {
1587
+ const state = envelopeDerivation(cached);
1588
+ const filesystem = resultFilesystem(cached.result);
1589
+ const graph = envelopeGraphIndexes(state, cached);
1590
+ const hashes = {};
1591
+ const realpaths = {};
1592
+ const signatures = {};
1593
+ let complete = true;
1594
+ // Sandwich every read between two metadata signatures. Only a signature that
1595
+ // survived its own read, and whose stamp's tick the filesystem's clock has
1596
+ // provably left ({@link stampSeparable}), may stand in for the content
1597
+ // comparison; a write racing the capture, or a stamp a same-tick rewrite
1598
+ // could still reproduce, leaves the input without one, so revalidation keeps
1599
+ // re-reading it.
1600
+ const record = (input, before, after) => {
1601
+ if (after !== undefined && before?.signature === after && before.separable)
1602
+ signatures[path.resolve(input)] = after;
1603
+ };
1604
+ for (const input of paths) {
1605
+ const identity = derivationIdentity(state, input);
1606
+ // A member the envelope reported only as a resolution candidate falls
1607
+ // through to the recorded-state branch below, the same evidence a
1608
+ // plugin-declared dependency path carries. Its absence still invalidates
1609
+ // the generation when it appears, because `missing` is recorded state.
1610
+ const speculativeOnly = graph.speculative.has(identity) &&
1611
+ !graph.inputProofs.has(identity) &&
1612
+ !graph.inputProofConflicts.has(identity);
1613
+ if (graph.members.has(identity) && !speculativeOnly) {
1614
+ const proof = graph.inputProofs.get(identity);
1615
+ if (proof === undefined || graph.inputProofConflicts.has(identity)) {
1616
+ complete = false;
1617
+ continue;
1618
+ }
1619
+ const before = inputMetadataEvidence(input, filesystem);
1620
+ const currentHash = graphInputStateHash(input, filesystem);
1621
+ const after = inputMetadataSignature(input, filesystem);
1622
+ if (currentHash !== proof.hash ||
1623
+ !sameHostInputRealpath(proof.realpath, hostInputRealpath(input, filesystem), state.identityContext)) {
1624
+ complete = false;
1625
+ }
1626
+ else if (currentHash !== null) {
1627
+ // The recorded hash is the compiler's own proof, so a signature may
1628
+ // only stand for it once the current bytes were shown to match it.
1629
+ // A path with no readable content has no bytes to stand for: it can
1630
+ // hold stable metadata while becoming readable, so it keeps the read.
1631
+ record(input, before, after);
1632
+ }
1633
+ hashes[identity] = proof.hash ?? MISSING_INPUT_STATE;
1634
+ realpaths[identity] = proof.realpath;
1635
+ continue;
1636
+ }
1637
+ const before = inputMetadataEvidence(input, filesystem);
1638
+ const hash = hostInputStateHash(input, filesystem);
1639
+ const after = inputMetadataSignature(input, filesystem);
1640
+ hashes[identity] = hash ?? MISSING_INPUT_STATE;
1641
+ if (hash !== null)
1642
+ record(input, before, after);
1643
+ }
1644
+ return { complete, hashes, realpaths, signatures };
1645
+ }
1646
+ /** Verify every graph member still has the state read by the compiler. */
1647
+ function matchesCompilerGraphInputProofs(cached) {
1648
+ if (cached.result.type === "exception" ||
1649
+ cached.result.graph === undefined ||
1650
+ (cached.result.graph.inputHashes === undefined &&
1651
+ cached.result.graph.inputRealpaths === undefined)) {
1652
+ // Legacy sidecars remain compatible for ordinary in-project graphs. Their
1653
+ // out-of-walk members are still rejected by captureExternalInputSnapshot,
1654
+ // where a post-compile snapshot cannot prove the compiler's generation.
1655
+ return true;
1656
+ }
1657
+ const state = envelopeDerivation(cached);
1658
+ const filesystem = resultFilesystem(cached.result);
1659
+ const graph = envelopeGraphIndexes(state, cached);
1660
+ if (graph.inputProofConflicts.size !== 0) {
1661
+ return false;
1662
+ }
1663
+ for (const identity of graph.members) {
1664
+ const proof = graph.inputProofs.get(identity);
1665
+ // A speculative candidate has no compile-time read to prove. Requiring one
1666
+ // would void every generation of every project whose resolution passes over
1667
+ // a higher-priority spelling, which is every project with a dependency
1668
+ // typed by a declaration file (samchon/ttsc#1245). It is validated instead
1669
+ // against the state {@link captureExternalInputSnapshot} recorded for it.
1670
+ if (proof === undefined && graph.speculative.has(identity)) {
1671
+ continue;
1672
+ }
1673
+ if (proof === undefined ||
1674
+ graphInputStateHash(proof.path, filesystem) !== proof.hash ||
1675
+ !sameHostInputRealpath(proof.realpath, hostInputRealpath(proof.path, filesystem), state.identityContext)) {
1676
+ return false;
1677
+ }
1678
+ }
1679
+ return true;
1680
+ }
1681
+ /** Compare one derived input with the snapshot that owned it at generation. */
1682
+ function matchesRecordedInput(cached, input) {
1683
+ const state = envelopeDerivation(cached);
1684
+ const filesystem = resultFilesystem(cached.result);
1685
+ const projectKey = toProjectKey(cached.projectRoot, input, state.identityContext);
1686
+ const projectHash = Object.prototype.hasOwnProperty.call(cached.inputHashes, projectKey)
1687
+ ? cached.inputHashes[projectKey]
1688
+ : undefined;
1689
+ const identity = derivationIdentity(state, input);
1690
+ const externalHash = (cached.externalInputHashes ?? {})[identity];
1691
+ const externalRealpaths = cached.externalInputRealpaths;
1692
+ const graphInput = externalRealpaths !== undefined &&
1693
+ Object.prototype.hasOwnProperty.call(externalRealpaths, identity);
1694
+ if (externalRealpaths !== undefined &&
1695
+ Object.prototype.hasOwnProperty.call(externalRealpaths, identity) &&
1696
+ !sameHostInputRealpath(externalRealpaths[identity], hostInputRealpath(input, filesystem), state.identityContext)) {
1697
+ return false;
1698
+ }
1699
+ // Prefer the out-of-walk spelling's own snapshot when it exists. A lexical
1700
+ // alias can point back into the walked project, where the physical target's
1701
+ // project hash is a different authority (and graph text uses BOM decoding).
1702
+ const recorded = externalHash ?? projectHash;
1703
+ if (recorded === undefined) {
1704
+ return false;
1705
+ }
1706
+ try {
1707
+ const current = graphInput
1708
+ ? graphInputStateHash(input, filesystem)
1709
+ : hostInputStateHash(input, filesystem);
1710
+ return recorded === (current ?? MISSING_INPUT_STATE);
1711
+ }
1712
+ catch {
1713
+ return recorded === MISSING_INPUT_STATE;
1714
+ }
1715
+ }
1716
+ /** Record a successfully selected module as delivered by this generation. */
1717
+ function markCachedSourceServed(cached, file) {
1718
+ (cached.servedFiles ??= new Set()).add(pathIdentityKey(file, envelopeDerivation(cached).identityContext));
1719
+ }
1720
+ /**
1721
+ * Hash every input file under `projectRoot` (the same walk universe
1722
+ * {@link matchesCachedSource} validates against), keyed by project-relative
1723
+ * slash path. Exported so hosts without a per-build boundary (`@ttsc/metro`)
1724
+ * can fold the identical input universe into their own cache fingerprints.
1725
+ */
1726
+ function collectProjectInputHashes(projectRoot, identities = createHostPathIdentityContext(), filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1727
+ return collectProjectInputSnapshot(projectRoot, identities, filesystem)
1728
+ .hashes;
1729
+ }
1730
+ /** Hash project files and snapshot the directory topology in one walk. */
1731
+ function collectProjectInputSnapshot(projectRoot, identities, filesystem = DEFAULT_FILESYSTEM_OPERATIONS, proven) {
1732
+ const hashes = {};
1733
+ const fileSignatures = {};
1734
+ const provenSignatures = {};
1735
+ const unstableFiles = new Set();
1736
+ let attributed = true;
1737
+ const walked = walkProjectInputs(projectRoot, filesystem);
1738
+ let complete = walked.complete;
1739
+ for (const file of walked.files) {
1740
+ try {
1741
+ const before = inputMetadataEvidence(file, filesystem);
1742
+ const key = toProjectKey(projectRoot, file, identities);
1743
+ // A file whose signature still equals the one captured around the read
1744
+ // that produced the recorded hash carries that content, so the whole
1745
+ // project does not have to be re-read to prove one delivery. A signature
1746
+ // that was already proven stays proven: its stamp has not moved since the
1747
+ // clock provably left its tick.
1748
+ if (before !== undefined &&
1749
+ proven !== undefined &&
1750
+ proven.signatures[key] === before.signature &&
1751
+ Object.prototype.hasOwnProperty.call(proven.hashes, key)) {
1752
+ hashes[key] = proven.hashes[key];
1753
+ fileSignatures[key] = before.signature;
1754
+ provenSignatures[key] = before.signature;
1755
+ continue;
1756
+ }
1757
+ const contents = filesystem.readFile(file);
1758
+ const after = inputMetadataSignature(file, filesystem);
1759
+ hashes[key] = hashText(contents);
1760
+ if (before === undefined ||
1761
+ after === undefined ||
1762
+ before.signature !== after) {
1763
+ complete = false;
1764
+ unstableFiles.add(key);
1765
+ }
1766
+ else {
1767
+ fileSignatures[key] = after;
1768
+ // Only a signature whose stamp's tick the filesystem's clock provably
1769
+ // left before this read may later stand in for the content comparison
1770
+ // ({@link stampSeparable}); the raw signature above still participates
1771
+ // in the generation-time stability comparison.
1772
+ if (before.separable) {
1773
+ provenSignatures[key] = after;
1774
+ }
1775
+ }
1776
+ }
1777
+ catch {
1778
+ // File watchers may observe a transform while another process is moving
1779
+ // or deleting files. The missing key invalidates older cache entries.
1780
+ complete = false;
1781
+ try {
1782
+ unstableFiles.add(toProjectKey(projectRoot, file, identities));
1783
+ }
1784
+ catch {
1785
+ // Without a key the failure cannot be attributed, so it keeps the
1786
+ // whole snapshot incomplete rather than being scoped away.
1787
+ attributed = false;
1788
+ }
1789
+ }
1790
+ }
1791
+ return {
1792
+ complete,
1793
+ directoryComplete: walked.complete && attributed,
1794
+ fileSignatures,
1795
+ hashes,
1796
+ projectDirectories: walked.directories,
1797
+ provenSignatures,
1798
+ unstableFiles,
1799
+ };
1800
+ }
1801
+ /**
1802
+ * Enumerate every regular file under `root`, skipping well-known output and
1803
+ * tooling directories (see {@link isIgnoredProjectDirectory}).
1804
+ *
1805
+ * Uses an iterative DFS instead of `fs.readdirSync` recursion to avoid
1806
+ * unbounded call-stack depth on deep project trees. The result is sorted so
1807
+ * that hash comparisons are deterministic across OS-level directory orderings.
1808
+ */
1809
+ function walkProjectInputs(root, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1810
+ let complete = true;
1811
+ const directories = [];
1812
+ const files = [];
1813
+ const stack = [root];
1814
+ while (stack.length !== 0) {
1815
+ const current = stack.pop();
1816
+ const before = projectDirectorySignature(current, filesystem);
1817
+ if (before === undefined) {
1818
+ complete = false;
1819
+ continue;
1820
+ }
1821
+ let entries;
1822
+ try {
1823
+ entries = filesystem.readdir(current);
1824
+ }
1825
+ catch {
1826
+ complete = false;
1827
+ continue;
1828
+ }
1829
+ const after = projectDirectorySignature(current, filesystem);
1830
+ if (after === undefined || before !== after) {
1831
+ complete = false;
1832
+ }
1833
+ directories.push({
1834
+ path: current,
1835
+ // If membership moved during enumeration, force the next delivery to
1836
+ // replace this generation instead of blessing a torn directory/file
1837
+ // snapshot as stable.
1838
+ signature: after !== undefined && before === after
1839
+ ? after
1840
+ : `unstable:${before}:${after ?? "missing"}`,
1841
+ });
1842
+ for (const entry of entries) {
1843
+ if (isIgnoredProjectDirectory(entry.name)) {
1844
+ continue;
1845
+ }
1846
+ const file = path.join(current, entry.name);
1847
+ if (entry.isDirectory()) {
1848
+ stack.push(file);
1849
+ }
1850
+ else if (entry.isFile()) {
1851
+ files.push(file);
1852
+ }
1853
+ }
1854
+ }
1855
+ directories.sort((left, right) => left.path.localeCompare(right.path));
1856
+ files.sort();
1857
+ return { complete, directories, files };
1858
+ }
1859
+ /** Return a cheap identity for one directory's immediate membership. */
1860
+ function projectDirectorySignature(directory, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1861
+ try {
1862
+ const stats = filesystem.statBigInt(directory);
1863
+ // Directory stamps are minted by the same clock as file stamps, so every
1864
+ // walk observation also raises the clock floor that separates them.
1865
+ observeFilesystemClock(filesystem, stats);
1866
+ if (!stats.isDirectory()) {
1867
+ return undefined;
1868
+ }
1869
+ return [
1870
+ stats.dev,
1871
+ stats.ino,
1872
+ stats.mode,
1873
+ stats.size,
1874
+ stats.mtimeNs,
1875
+ stats.ctimeNs,
1876
+ ].join(":");
1877
+ }
1878
+ catch {
1879
+ return undefined;
1880
+ }
1881
+ }
1882
+ /** Compare two deterministic project-directory membership snapshots. */
1883
+ function sameProjectDirectories(left, right) {
1884
+ return (left.length === right.length &&
1885
+ left.every((directory, index) => directory.path === right[index]?.path &&
1886
+ directory.signature === right[index]?.signature));
1887
+ }
1888
+ /**
1889
+ * Open one directory's change notification through the cache-owned watch seam,
1890
+ * falling back to the host's own `fs.watch`. Throws exactly where the
1891
+ * underlying watch does, so callers classify a registration failure
1892
+ * themselves.
1893
+ */
1894
+ function openDirectoryWatch(filesystem, directory, listener, onError) {
1895
+ if (filesystem.watch !== undefined) {
1896
+ return filesystem.watch(directory, listener, onError);
1897
+ }
1898
+ const watcher = fs.watch(directory, { persistent: false }, (eventType, filename) => listener(eventType, filename === null ? null : String(filename)));
1899
+ watcher.on("error", onError);
1900
+ return { close: () => watcher.close() };
1901
+ }
1902
+ /** Watch every walked directory for membership changes after generation. */
1903
+ async function createProjectMutationTracker(directories, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1904
+ const tracker = {
1905
+ close: () => undefined,
1906
+ failed: false,
1907
+ membershipChanged: false,
1908
+ };
1909
+ if (process.platform === "win32" && filesystem.watch === undefined) {
1910
+ await registerWindowsProjectMutationTracker(tracker, directories.map((directory) => ({ directory: directory.path })), false, filesystem);
1911
+ return tracker;
1912
+ }
1913
+ const watchers = [];
1914
+ tracker.close = () => {
1915
+ for (const watcher of watchers)
1916
+ watcher.close();
1917
+ watchers.length = 0;
1918
+ };
1919
+ for (const directory of directories) {
1920
+ try {
1921
+ watchers.push(openDirectoryWatch(filesystem, directory.path, (eventType) => {
1922
+ if (eventType === "rename")
1923
+ tracker.membershipChanged = true;
1924
+ }, () => {
1925
+ tracker.failed = true;
1926
+ }));
1927
+ }
1928
+ catch {
1929
+ tracker.failed = true;
1930
+ }
1931
+ }
1932
+ return tracker;
1933
+ }
1934
+ /** Watch exact universal inputs, or their nearest existing parent if missing. */
1935
+ async function createHostInputMutationTracker(inputs, filesystem, covered, events = "all") {
1936
+ const identities = createHostPathIdentityContext(filesystem);
1937
+ const namesByDirectory = new Map();
1938
+ for (const input of inputs) {
1939
+ const absolute = path.resolve(input);
1940
+ const probe = filesystem.exists(absolute)
1941
+ ? { directory: path.dirname(absolute), name: path.basename(absolute) }
1942
+ : missingPathProbe(absolute, filesystem);
1943
+ const directoryIdentity = identities.resolve(probe.directory);
1944
+ let location = namesByDirectory.get(directoryIdentity.key);
1945
+ if (location === undefined) {
1946
+ location = {
1947
+ directory: directoryIdentity.path,
1948
+ names: new Set(),
1949
+ };
1950
+ namesByDirectory.set(directoryIdentity.key, location);
1951
+ }
1952
+ location.names.add(normalizeHostInputName(probe.name, identities.caseSensitive(directoryIdentity.path)));
1953
+ }
1954
+ const locations = [...namesByDirectory.values()].map((location) => ({
1955
+ directory: location.directory,
1956
+ names: [...location.names],
1957
+ }));
1958
+ const tracker = {
1959
+ close: () => undefined,
1960
+ // Coverage is the caller's claim, and it is required rather than derived
1961
+ // from the input list: an input is watched by its exact name here, but only
1962
+ // the caller knows whether the path leading to it is watched as well, which
1963
+ // is what a later validation needs before it trusts the watcher instead of
1964
+ // probing the path again. Deriving it here would hand that claim to every
1965
+ // future caller by default (samchon/ttsc#1261).
1966
+ covered,
1967
+ failed: false,
1968
+ membershipChanged: false,
1969
+ };
1970
+ if (process.platform === "win32" && filesystem.watch === undefined) {
1971
+ await registerWindowsProjectMutationTracker(tracker, locations, events === "all", filesystem);
1972
+ return tracker;
1973
+ }
1974
+ const watchers = [];
1975
+ tracker.close = () => {
1976
+ for (const watcher of watchers)
1977
+ watcher.close();
1978
+ watchers.length = 0;
1979
+ };
1980
+ for (const location of locations) {
1981
+ try {
1982
+ const names = new Set(location.names);
1983
+ const caseSensitive = identities.caseSensitive(location.directory);
1984
+ watchers.push(openDirectoryWatch(filesystem, location.directory, (eventType, filename) => {
1985
+ if (events === "rename" && eventType !== "rename") {
1986
+ return;
1987
+ }
1988
+ const reported = filename === null
1989
+ ? null
1990
+ : normalizeHostInputName(filename, caseSensitive);
1991
+ if (reported === null || names.has(reported)) {
1992
+ tracker.membershipChanged = true;
1993
+ }
1994
+ }, () => {
1995
+ tracker.failed = true;
1996
+ }));
1997
+ }
1998
+ catch {
1999
+ tracker.failed = true;
2000
+ }
2001
+ }
2002
+ return tracker;
2003
+ }
2004
+ let windowsProjectMutationBroker;
2005
+ /**
2006
+ * Register directory watches in an isolated Windows process.
2007
+ *
2008
+ * Node's Windows fs-event backend can assert in native code when a watched
2009
+ * temporary tree is deleted. Isolation turns that unrecoverable process abort
2010
+ * into an ordinary broker exit and a conservative cache miss in the host.
2011
+ */
2012
+ async function registerWindowsProjectMutationTracker(tracker, locations, allEvents, filesystem) {
2013
+ const broker = getWindowsProjectMutationBroker();
2014
+ const normalized = locations.map((location) => {
2015
+ let directory;
2016
+ try {
2017
+ directory = filesystem.realpath(location.directory);
2018
+ }
2019
+ catch {
2020
+ directory = path.resolve(location.directory);
2021
+ }
2022
+ return {
2023
+ directory,
2024
+ ...(location.names === undefined ? {} : { names: location.names }),
2025
+ };
2026
+ });
2027
+ broker.pendingRegistrations += 1;
2028
+ broker.child.ref();
2029
+ broker.child.channel?.ref();
2030
+ const id = broker.nextId++;
2031
+ let resolveReady;
2032
+ const ready = new Promise((resolve) => {
2033
+ resolveReady = resolve;
2034
+ });
2035
+ broker.trackers.set(id, { ready: resolveReady, tracker });
2036
+ tracker.drain = () => drainWindowsProjectMutationBroker(broker);
2037
+ tracker.close = () => {
2038
+ const active = broker.trackers.get(id);
2039
+ if (active === undefined)
2040
+ return;
2041
+ broker.trackers.delete(id);
2042
+ active.ready();
2043
+ broker.child.send?.({ id, op: "remove" });
2044
+ if (broker.trackers.size === 0) {
2045
+ broker.child.disconnect?.();
2046
+ broker.child.kill();
2047
+ if (windowsProjectMutationBroker === broker) {
2048
+ windowsProjectMutationBroker = undefined;
2049
+ }
2050
+ }
2051
+ };
2052
+ broker.child.send?.({
2053
+ allEvents,
2054
+ locations: normalized,
2055
+ id,
2056
+ op: "add",
2057
+ });
2058
+ try {
2059
+ await ready;
2060
+ }
2061
+ finally {
2062
+ broker.pendingRegistrations -= 1;
2063
+ // `ref`/`unref` is a flag rather than a counter, so this must not clear a
2064
+ // reference an in-flight acknowledgement is holding: a delivery waiting on
2065
+ // a reply over an unreferenced channel lets the loop empty and the process
2066
+ // exit mid-build.
2067
+ if (broker.pendingRegistrations === 0 && broker.pendingDrains === 0) {
2068
+ broker.child.unref();
2069
+ broker.child.channel?.unref();
2070
+ }
2071
+ }
2072
+ }
2073
+ function getWindowsProjectMutationBroker() {
2074
+ if (windowsProjectMutationBroker !== undefined) {
2075
+ return windowsProjectMutationBroker;
2076
+ }
2077
+ const child = spawn(process.execPath, ["-e", WINDOWS_WATCH_BROKER_SOURCE], {
2078
+ stdio: ["ignore", "ignore", "ignore", "ipc"],
2079
+ windowsHide: true,
2080
+ });
2081
+ const broker = {
2082
+ child,
2083
+ drains: new Map(),
2084
+ nextId: 1,
2085
+ pendingDrains: 0,
2086
+ pendingRegistrations: 0,
2087
+ trackers: new Map(),
2088
+ };
2089
+ const fail = () => {
2090
+ for (const registration of broker.trackers.values()) {
2091
+ registration.tracker.failed = true;
2092
+ registration.ready();
2093
+ }
2094
+ broker.trackers.clear();
2095
+ // A broker that died answers no round-trip. Release every waiter instead of
2096
+ // stalling the deliveries behind them; their trackers are failed now, so
2097
+ // validation falls back to proving the generation from its own state.
2098
+ for (const release of broker.drains.values())
2099
+ release();
2100
+ broker.drains.clear();
2101
+ if (windowsProjectMutationBroker === broker) {
2102
+ windowsProjectMutationBroker = undefined;
2103
+ }
2104
+ };
2105
+ child.on("error", fail);
2106
+ child.on("exit", fail);
2107
+ child.on("message", (message) => {
2108
+ if (message === null || typeof message !== "object")
2109
+ return;
2110
+ const record = message;
2111
+ if (typeof record.id !== "number")
2112
+ return;
2113
+ if (record.drained === true) {
2114
+ // Every event the child had already sent arrived before this reply, since
2115
+ // one IPC channel delivers in order.
2116
+ const release = broker.drains.get(record.id);
2117
+ broker.drains.delete(record.id);
2118
+ release?.();
2119
+ return;
2120
+ }
2121
+ const registration = broker.trackers.get(record.id);
2122
+ if (registration === undefined)
2123
+ return;
2124
+ if (record.failed === true)
2125
+ registration.tracker.failed = true;
2126
+ if (record.ready === true)
2127
+ registration.ready();
2128
+ if (record.ready !== true && record.failed !== true) {
2129
+ registration.tracker.membershipChanged = true;
2130
+ }
2131
+ });
2132
+ windowsProjectMutationBroker = broker;
2133
+ return broker;
2134
+ }
2135
+ /**
2136
+ * Ask the Windows broker to acknowledge, and resolve when it does.
2137
+ *
2138
+ * The child answers after a turn of its own loop, so a watch callback it had
2139
+ * already queued has run, and the ordered IPC channel puts every message it
2140
+ * sent before the reply ahead of the reply. That is the same proof an
2141
+ * in-process watcher gets from a macrotask turn, rather than the fixed wait
2142
+ * this replaces, which guessed at the crossing (samchon/ttsc#1272).
2143
+ *
2144
+ * A broker that never answers must not hold a delivery: the wait falls back to
2145
+ * the previous fixed grace, after which validation proceeds against whatever
2146
+ * the tracker knows, exactly as it did before.
2147
+ */
2148
+ function drainWindowsProjectMutationBroker(broker) {
2149
+ // Every tracker of a generation lives in one broker, so one acknowledgement
2150
+ // answers for all of them. Sharing the in-flight round-trip keeps a settle to
2151
+ // a single crossing.
2152
+ broker.draining ??= startWindowsProjectMutationDrain(broker).finally(() => {
2153
+ broker.draining = undefined;
2154
+ });
2155
+ return broker.draining;
2156
+ }
2157
+ function startWindowsProjectMutationDrain(broker) {
2158
+ return new Promise((resolve) => {
2159
+ const id = broker.nextId++;
2160
+ let settled = false;
2161
+ const release = () => {
2162
+ if (settled)
2163
+ return;
2164
+ settled = true;
2165
+ clearTimeout(timer);
2166
+ broker.drains.delete(id);
2167
+ broker.pendingDrains -= 1;
2168
+ if (broker.pendingDrains === 0 && broker.pendingRegistrations === 0) {
2169
+ broker.child.unref();
2170
+ broker.child.channel?.unref();
2171
+ }
2172
+ resolve();
2173
+ };
2174
+ // Hold the channel open while the acknowledgement is outstanding. The
2175
+ // broker is unreferenced between requests so it never keeps a host alive,
2176
+ // and a reply is the only thing this promise can be resolved by: without
2177
+ // the reference the loop can empty while a delivery waits here, and the
2178
+ // process exits mid-build with nothing to report.
2179
+ broker.pendingDrains += 1;
2180
+ broker.child.ref();
2181
+ broker.child.channel?.ref();
2182
+ const timer = setTimeout(release, WINDOWS_MUTATION_DRAIN_FALLBACK_MS);
2183
+ broker.drains.set(id, release);
2184
+ if (broker.child.send?.({ id, op: "drain" }) !== true) {
2185
+ release();
2186
+ }
2187
+ });
2188
+ }
2189
+ /** The wait a broker that stopped answering degrades to. */
2190
+ const WINDOWS_MUTATION_DRAIN_FALLBACK_MS = 10;
2191
+ const WINDOWS_WATCH_BROKER_SOURCE = [
2192
+ 'const fs = require("node:fs");',
2193
+ "const groups = new Map();",
2194
+ 'process.on("message", (message) => {',
2195
+ ' if (message.op === "drain") {',
2196
+ // Two turns, not one: the first lets the loop poll for watch completions the
2197
+ // kernel had already queued, the second answers after their callbacks ran.
2198
+ " setImmediate(() => setImmediate(() => process.send?.({ drained: true, id: message.id })));",
2199
+ " return;",
2200
+ " }",
2201
+ ' if (message.op === "remove") {',
2202
+ " close(message.id);",
2203
+ " return;",
2204
+ " }",
2205
+ ' if (message.op !== "add") return;',
2206
+ " const watchers = [];",
2207
+ " let failed = false;",
2208
+ " for (const location of message.locations) {",
2209
+ " try {",
2210
+ " const names = location.names === undefined ? undefined : new Set(location.names.map((name) => name.toLowerCase()));",
2211
+ " const watcher = fs.watch(location.directory, { persistent: false }, (event, filename) => {",
2212
+ " const matches = names === undefined || filename === null || names.has(String(filename).toLowerCase());",
2213
+ ' if (matches && (message.allEvents || event === "rename")) process.send?.({ id: message.id });',
2214
+ " });",
2215
+ ' watcher.on("error", () => process.send?.({ failed: true, id: message.id }));',
2216
+ " watchers.push(watcher);",
2217
+ " } catch {",
2218
+ " failed = true;",
2219
+ " }",
2220
+ " }",
2221
+ " groups.set(message.id, watchers);",
2222
+ " process.send?.({ failed, id: message.id, ready: true });",
2223
+ "});",
2224
+ 'process.on("disconnect", () => {',
2225
+ " for (const id of groups.keys()) close(id);",
2226
+ " process.exit(0);",
2227
+ "});",
2228
+ "function close(id) {",
2229
+ " for (const watcher of groups.get(id) ?? []) watcher.close();",
2230
+ " groups.delete(id);",
2231
+ "}",
2232
+ ].join("\n");
2233
+ /**
2234
+ * Report whether either live notification observed a membership event. This is
2235
+ * positive evidence that the generation is stale, so it outranks the question
2236
+ * of whether the notifications still work.
2237
+ */
2238
+ function reportsMembershipChange(cached) {
2239
+ return (cached.projectMutationTracker?.membershipChanged === true ||
2240
+ cached.hostInputMutationTracker?.membershipChanged === true ||
2241
+ cached.candidateMutationTracker?.membershipChanged === true);
2242
+ }
2243
+ /**
2244
+ * Report whether the live notifications can still prove membership. A watcher
2245
+ * that failed to register, or that errored after the generation was produced,
2246
+ * proves nothing either way — it never proves the generation stale.
2247
+ */
2248
+ function notificationsProveMembership(cached) {
2249
+ for (const tracker of [
2250
+ cached.projectMutationTracker,
2251
+ cached.hostInputMutationTracker,
2252
+ ]) {
2253
+ if (tracker === undefined || tracker.failed) {
2254
+ return false;
2255
+ }
2256
+ }
2257
+ // The candidate tracker is optional: a generation with no absent candidate
2258
+ // opens none, and one that declined to watch them left the per-delivery probe
2259
+ // in place. Only a tracker that exists and has failed withdraws the proof.
2260
+ return cached.candidateMutationTracker?.failed !== true;
2261
+ }
2262
+ /**
2263
+ * Yield to the loop the tracker's own watcher callbacks are queued on.
2264
+ *
2265
+ * Two turns for the same reason the broker takes two: the first gives the loop
2266
+ * a poll phase for completions the kernel had already queued, the second runs
2267
+ * after the callbacks they produced.
2268
+ */
2269
+ function drainOnNextTurn() {
2270
+ return new Promise((resolve) => setImmediate(() => setImmediate(resolve)));
2271
+ }
2272
+ /**
2273
+ * Settle every notification the trackers' watchers have already dispatched,
2274
+ * before persistent validation reads their verdict.
2275
+ *
2276
+ * A synchronous edit returns before its watch event is applied, so without this
2277
+ * a delivery could validate against a tracker that has not been told yet. Each
2278
+ * tracker drains through its own channel, which is a macrotask turn for a
2279
+ * watcher on this loop and an ordered round-trip for one inside the Windows
2280
+ * broker. Concurrent sibling deliveries share the barrier one of them started.
2281
+ */
2282
+ async function settleProjectMutationEvents(cached) {
2283
+ const trackers = [
2284
+ cached.projectMutationTracker,
2285
+ cached.hostInputMutationTracker,
2286
+ cached.candidateMutationTracker,
2287
+ ].filter((tracker) => tracker !== undefined);
2288
+ await Promise.all(trackers.map(async (tracker) => {
2289
+ tracker.settle ??= (tracker.drain ?? drainOnNextTurn)().finally(() => {
2290
+ tracker.settle = undefined;
2291
+ });
2292
+ await tracker.settle;
2293
+ }));
2294
+ }
2295
+ /**
2296
+ * Report whether an absolute `file` belongs to the project walk universe of
2297
+ * `root`: it lies under `root`, every component exists without traversing a
2298
+ * symbolic link, the leaf is a regular file, and no segment of the relative
2299
+ * path is ignored. The predicate mirrors {@link walkProjectInputs} exactly, so
2300
+ * "walk-visible" here means "hashed by {@link collectProjectInputHashes}".
2301
+ * Missing paths and files reached through symlinks or Windows junctions are
2302
+ * out-of-walk inputs that only the reference graph can prove relevant.
2303
+ */
2304
+ function isProjectWalkPath(root, file, _identities = createHostPathIdentityContext(), filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
2305
+ // Walk membership is lexical. Resolving `file` to physical identity first
2306
+ // would turn `root/alias/value.ts` into `root/target/value.ts`, hide the
2307
+ // symlink segment from the lstat loop below, and falsely claim the project
2308
+ // walk hashed a path it deliberately never followed.
2309
+ const resolvedRoot = path.resolve(root);
2310
+ const relative = path.relative(resolvedRoot, path.resolve(file));
2311
+ if (relative.length === 0 ||
2312
+ relative === ".." ||
2313
+ relative.startsWith(`..${path.sep}`) ||
2314
+ path.isAbsolute(relative)) {
2315
+ return false;
2316
+ }
2317
+ const segments = relative.split(path.sep);
2318
+ if (segments.some(isIgnoredProjectDirectory)) {
2319
+ return false;
2320
+ }
2321
+ let current = resolvedRoot;
2322
+ for (let index = 0; index < segments.length; ++index) {
2323
+ current = path.join(current, segments[index]);
2324
+ let stats;
2325
+ try {
2326
+ stats = filesystem.lstat(current);
2327
+ }
2328
+ catch {
2329
+ return false;
2330
+ }
2331
+ if (stats.isSymbolicLink()) {
2332
+ return false;
2333
+ }
2334
+ const leaf = index === segments.length - 1;
2335
+ if ((leaf && !stats.isFile()) || (!leaf && !stats.isDirectory())) {
2336
+ return false;
2337
+ }
2338
+ }
2339
+ return true;
2340
+ }
2341
+ /**
2342
+ * Hash a list of absolute out-of-walk input paths: content SHA-256 for a
2343
+ * readable file, a stable directory-kind digest for a directory candidate, and
2344
+ * a stable `missing` marker otherwise. Keys use filesystem identity so
2345
+ * case-only spellings share one snapshot entry, while reads retain the original
2346
+ * path supplied by the compiler. The marker is state, not an error — a recorded
2347
+ * input disappearing (or reappearing) must change the comparison exactly like a
2348
+ * content edit. Exported so `@ttsc/metro` can re-hash its recorded snapshot
2349
+ * with identical semantics at cache-key time.
2350
+ */
2351
+ function collectExternalInputHashes(paths, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
2352
+ const hashes = {};
2353
+ const identities = createHostPathIdentityContext(filesystem);
2354
+ for (const file of paths) {
2355
+ const identity = pathIdentityKey(file, identities);
2356
+ if (identity in hashes) {
2357
+ continue;
2358
+ }
2359
+ hashes[identity] =
2360
+ hostInputStateHash(file, filesystem) ?? MISSING_INPUT_STATE;
2361
+ }
2362
+ return hashes;
2363
+ }
2364
+ /**
2365
+ * Re-check a cached mixed graph/dependency input set with its owning codec,
2366
+ * reusing the recorded hash of any input whose metadata signature still holds
2367
+ * and reporting the signatures this pass captured.
2368
+ *
2369
+ * The caller adopts those signatures only once every input is proven unchanged,
2370
+ * so a signature never outlives the content comparison that justified it.
2371
+ */
2372
+ function matchesCachedExternalInputs(cached) {
2373
+ const signatures = {};
2374
+ let matches = true;
2375
+ const state = envelopeDerivation(cached);
2376
+ const graphRealpaths = cached.externalInputRealpaths ?? {};
2377
+ const filesystem = resultFilesystem(cached.result);
2378
+ const recordedHashes = cached.externalInputHashes ?? {};
2379
+ const recordedSignatures = cached.externalInputSignatures ?? {};
2380
+ // Compare each spelling against the recorded state under its own name. Two
2381
+ // spellings share one identity exactly when they selected one physical file
2382
+ // at generation time, which is the state a retarget ends, so neither may
2383
+ // answer for the other: skipping the second would leave a retargeted alias
2384
+ // unvalidated, and comparing them only through a shared key would let
2385
+ // whichever came first decide.
2386
+ for (const file of cached.externalInputPaths ??
2387
+ Object.keys(cached.externalInputHashes ?? {})) {
2388
+ const identity = derivationIdentity(state, file);
2389
+ const spelling = path.resolve(file);
2390
+ // Reuse the recorded hash of an out-of-walk input whose signature still
2391
+ // equals the one captured around the read that proved it. The signature is
2392
+ // keyed by this exact spelling, so an alias of the same physical file
2393
+ // cannot answer for it.
2394
+ const before = inputMetadataEvidence(file, filesystem);
2395
+ if (before !== undefined &&
2396
+ Object.prototype.hasOwnProperty.call(recordedSignatures, spelling) &&
2397
+ Object.prototype.hasOwnProperty.call(recordedHashes, identity) &&
2398
+ before.signature === recordedSignatures[spelling]) {
2399
+ continue;
2400
+ }
2401
+ const hash = Object.prototype.hasOwnProperty.call(graphRealpaths, identity)
2402
+ ? graphInputStateHash(file, filesystem)
2403
+ : hostInputStateHash(file, filesystem);
2404
+ const after = inputMetadataSignature(file, filesystem);
2405
+ if (!Object.prototype.hasOwnProperty.call(recordedHashes, identity) ||
2406
+ recordedHashes[identity] !== (hash ?? MISSING_INPUT_STATE)) {
2407
+ matches = false;
2408
+ }
2409
+ if (hash !== null &&
2410
+ after !== undefined &&
2411
+ before?.signature === after &&
2412
+ before.separable) {
2413
+ signatures[spelling] = after;
2414
+ }
2415
+ }
2416
+ return { matches, signatures };
2417
+ }
2418
+ /**
2419
+ * Derive the absolute out-of-walk input set of a whole project transform: the
2420
+ * union of every reference-graph member (edge keys and targets, globals, the
2421
+ * config chain) and every plugin-reported dependency, minus everything the
2422
+ * project walk already hashes and the disposed temp-dir tsconfig. These are the
2423
+ * inputs {@link matchesCachedSource}'s walk cannot see. Resolution candidates
2424
+ * that are still missing remain in this set even under the project root: the
2425
+ * first walk cannot hash a file that has not been created yet.
2426
+ *
2427
+ * A `dependenciesComplete` declaration deliberately does not narrow the stored
2428
+ * set: other files in the same whole-project result can still own the omitted
2429
+ * members. Persistent validation selects the requested file's subset through
2430
+ * {@link selectWatchInputs}, while graph-free envelopes use this union as their
2431
+ * conservative fallback.
2432
+ */
2433
+ function selectExternalInputPaths(props) {
2434
+ if (props.result.type === "exception") {
2435
+ return [];
849
2436
  }
850
2437
  const members = [];
851
- const identities = createHostPathIdentityContext();
2438
+ const filesystem = props.filesystem ?? DEFAULT_FILESYSTEM_OPERATIONS;
2439
+ const identities = createHostPathIdentityContext(filesystem);
852
2440
  const resolutionCandidates = new Set();
853
2441
  const graph = props.result.graph;
854
2442
  if (graph !== undefined) {
@@ -882,6 +2470,17 @@ function selectExternalInputPaths(props) {
882
2470
  members.push(...entries);
883
2471
  }
884
2472
  }
2473
+ if (Array.isArray(props.result.hostInputs)) {
2474
+ for (const input of props.result.hostInputs) {
2475
+ members.push(input);
2476
+ if (typeof input === "string" && input.length !== 0) {
2477
+ // Plugin discovery inputs deliberately include absent config and
2478
+ // resolution probes. A project walk cannot snapshot a path that does
2479
+ // not exist yet, even when its spelling lies below projectRoot.
2480
+ resolutionCandidates.add(pathIdentityKey(path.resolve(props.projectRoot, input), identities));
2481
+ }
2482
+ }
2483
+ }
885
2484
  const excluded = props.temporaryTsconfig === undefined
886
2485
  ? undefined
887
2486
  : pathIdentityKey(props.temporaryTsconfig, identities);
@@ -892,20 +2491,173 @@ function selectExternalInputPaths(props) {
892
2491
  continue;
893
2492
  }
894
2493
  const absolute = path.resolve(props.projectRoot, member);
2494
+ const spelling = path.resolve(absolute);
895
2495
  const identity = pathIdentityKey(absolute, identities);
896
- const missingCandidate = resolutionCandidates.has(identity) && !fs.existsSync(absolute);
2496
+ const missingCandidate = resolutionCandidates.has(identity) && !filesystem.exists(absolute);
897
2497
  if (identity === excluded ||
898
- seen.has(identity) ||
2498
+ seen.has(spelling) ||
899
2499
  (!missingCandidate &&
900
- isProjectWalkPath(props.projectRoot, absolute, identities))) {
2500
+ isProjectWalkPath(props.projectRoot, absolute, identities, filesystem))) {
901
2501
  continue;
902
2502
  }
903
- seen.add(identity);
2503
+ // Preserve distinct lexical aliases even when they currently select the
2504
+ // same physical file. A later retarget must validate the alias itself.
2505
+ seen.add(spelling);
904
2506
  output.push(absolute);
905
2507
  }
906
2508
  output.sort();
907
2509
  return output;
908
2510
  }
2511
+ /**
2512
+ * The generation's resolution candidates that do not exist, so its host-input
2513
+ * watcher can be told to announce their creation.
2514
+ *
2515
+ * A missing candidate is the one input class no proof can be memoized for: its
2516
+ * metadata cannot be read, so the signature shortcut that stands in for every
2517
+ * other input's comparison never applies, and every delivery that reaches it
2518
+ * probes the filesystem again. Watching the name instead turns that repeated
2519
+ * probe into one notification for the whole generation, using the same channel
2520
+ * and the same failure rules the universal inputs already run under
2521
+ * (samchon/ttsc#1261).
2522
+ *
2523
+ * Only absent candidates qualify. One that exists is validated by content and
2524
+ * physical identity like any other input, and adding it here would replace the
2525
+ * generation for a change that cannot affect a resolution the compiler already
2526
+ * declined to take.
2527
+ */
2528
+ function selectNotifiableAbsentInputs(props) {
2529
+ const empty = { candidates: [], watched: [] };
2530
+ if (props.result.type === "exception") {
2531
+ return empty;
2532
+ }
2533
+ const graph = props.result.graph;
2534
+ if (graph === undefined) {
2535
+ return empty;
2536
+ }
2537
+ const identities = createHostPathIdentityContext(props.filesystem);
2538
+ const excluded = props.temporaryTsconfig === undefined
2539
+ ? undefined
2540
+ : pathIdentityKey(props.temporaryTsconfig, identities);
2541
+ const resolvedProjectRoot = path.resolve(props.projectRoot);
2542
+ const output = [];
2543
+ const watched = [];
2544
+ const directories = new Set();
2545
+ // Two namespaces, deliberately not one set: candidates are the paths a
2546
+ // delivery may stop probing, while the chain holds the directories that carry
2547
+ // them. Sharing a set would let one silently answer for the other.
2548
+ const seen = new Set();
2549
+ const chain = new Set();
2550
+ for (const candidates of Object.values(graph.candidates ?? {})) {
2551
+ if (!Array.isArray(candidates)) {
2552
+ continue;
2553
+ }
2554
+ for (const candidate of candidates) {
2555
+ if (typeof candidate !== "string" || candidate.length === 0) {
2556
+ continue;
2557
+ }
2558
+ const absolute = path.resolve(props.projectRoot, candidate);
2559
+ const spelling = path.resolve(absolute);
2560
+ if (seen.has(spelling) ||
2561
+ (excluded !== undefined &&
2562
+ pathIdentityKey(absolute, identities) === excluded) ||
2563
+ props.filesystem.exists(absolute)) {
2564
+ continue;
2565
+ }
2566
+ seen.add(spelling);
2567
+ // Collect the components of the lexical path, by the name each carries in
2568
+ // its own parent. The watcher a missing path opens follows the spelling
2569
+ // to a physical directory, so retargeting a link along the way moves the
2570
+ // answer without touching what is watched: in a pnpm layout
2571
+ // `node_modules/<pkg>` is exactly such a link, and reinstalling it makes
2572
+ // a candidate appear behind a watch still looking at the old store
2573
+ // directory. Watching `<pkg>` inside `node_modules` is what reports that.
2574
+ //
2575
+ // The collection stops at the project root, and a spelling that leaves
2576
+ // the project subtree before reaching it is not claimed at all. Above
2577
+ // that line the components are the machine's own layout rather than the
2578
+ // project's, and watching those entries costs a generation whenever an
2579
+ // unrelated process touches anything inside them; a candidate whose path
2580
+ // runs outside the subtree therefore keeps the probe it always had rather
2581
+ // than a proof this cannot complete.
2582
+ const components = [];
2583
+ let reachedProject = false;
2584
+ for (let child = path.dirname(spelling), parent = path.dirname(child); parent !== child; child = parent, parent = path.dirname(child)) {
2585
+ if (insideProject(child, resolvedProjectRoot)) {
2586
+ components.push(child);
2587
+ continue;
2588
+ }
2589
+ // Compared through `path.relative` rather than by string, so a
2590
+ // spelling that differs from the root only in case still counts as
2591
+ // having arrived where the platform says it has.
2592
+ reachedProject = path.relative(child, resolvedProjectRoot).length === 0;
2593
+ break;
2594
+ }
2595
+ if (!reachedProject) {
2596
+ continue;
2597
+ }
2598
+ output.push(absolute);
2599
+ watched.push(absolute);
2600
+ for (const component of components) {
2601
+ if (chain.has(component))
2602
+ break;
2603
+ chain.add(component);
2604
+ watched.push(component);
2605
+ directories.add(path.dirname(component));
2606
+ }
2607
+ directories.add(path.dirname(spelling));
2608
+ }
2609
+ }
2610
+ if (directories.size > NOTIFIABLE_ABSENCE_DIRECTORY_LIMIT) {
2611
+ // Past this many distinct directories the watch registration is the more
2612
+ // expensive half: a host that runs out of watch descriptors fails the
2613
+ // tracker, and a failed tracker sends every delivery to complete-snapshot
2614
+ // validation, which re-hashes the whole project. Declining to watch leaves
2615
+ // the per-delivery probe in place, which is what this replaces and is far
2616
+ // cheaper than that.
2617
+ return empty;
2618
+ }
2619
+ output.sort();
2620
+ watched.sort();
2621
+ return { candidates: output, watched };
2622
+ }
2623
+ /**
2624
+ * Report whether a directory lies strictly below the project root.
2625
+ *
2626
+ * The boundary of what a generation may watch on a candidate's behalf: what the
2627
+ * project contains is its own layout, while the project root and everything
2628
+ * above it belongs to the machine, which nobody retargets and which changes for
2629
+ * reasons no generation should hear about.
2630
+ */
2631
+ function insideProject(directory, projectRoot) {
2632
+ const relative = path.relative(path.resolve(projectRoot), path.resolve(directory));
2633
+ // An empty result is the platform saying the two name the same directory,
2634
+ // which it answers for spellings that differ only in case where the path
2635
+ // module folds case. The root itself is not below itself, so the walk stops
2636
+ // there rather than one level past it.
2637
+ if (relative.length === 0) {
2638
+ return false;
2639
+ }
2640
+ // `..` alone and `../` climb out, and an absolute answer means another drive
2641
+ // or share entirely; a directory literally named `..x` does neither, which a
2642
+ // plain prefix test would misread. The project walk's own containment check
2643
+ // spells it the same way.
2644
+ return (relative !== ".." &&
2645
+ !relative.startsWith(`..${path.sep}`) &&
2646
+ !path.isAbsolute(relative));
2647
+ }
2648
+ /**
2649
+ * Distinct directories the absent-candidate watch may open before it declines.
2650
+ *
2651
+ * Sized well below the inotify per-user default so a project's own walk keeps
2652
+ * its share, and far above the distinct `node_modules` package directories a
2653
+ * real dependency graph produces.
2654
+ *
2655
+ * Counted lexically, over the parents of every watched name. A missing subtree
2656
+ * collapses onto the one watch its nearest existing ancestor carries, so the
2657
+ * count is an upper bound on the watches actually opened rather than their
2658
+ * number; the bound stays sound and is merely not tight.
2659
+ */
2660
+ const NOTIFIABLE_ABSENCE_DIRECTORY_LIMIT = 512;
909
2661
  function isIgnoredProjectDirectory(name) {
910
2662
  return (name === ".git" ||
911
2663
  name === ".ttsc" ||
@@ -923,7 +2675,29 @@ function isIgnoredProjectDirectory(name) {
923
2675
  name === "temp" ||
924
2676
  name === "tmp");
925
2677
  }
926
- function sameHashes(left, right) {
2678
+ /**
2679
+ * Compare two project-walk snapshots.
2680
+ *
2681
+ * `keys` narrows the comparison to the generation's declared inputs. The walk
2682
+ * hashes every file under the project root, but only a file the compile
2683
+ * actually consumed can change an output, and a project root is a working
2684
+ * directory: a framework's generated types, a log, a coverage report, or a test
2685
+ * artifact appears and changes there while a compile runs. Comparing those
2686
+ * would declare the generation incoherent and cost a whole-project recompile
2687
+ * for every remaining module (samchon/ttsc#1246). Files entering or leaving the
2688
+ * project remain covered by the directory-membership snapshot, which is the one
2689
+ * thing a content comparison cannot see. An envelope that declares no input set
2690
+ * (a graph-free legacy host) passes `undefined` and keeps the whole-walk
2691
+ * comparison.
2692
+ */
2693
+ function sameHashes(left, right, keys) {
2694
+ if (keys !== undefined) {
2695
+ for (const key of keys) {
2696
+ if (left[key] !== right[key])
2697
+ return false;
2698
+ }
2699
+ return true;
2700
+ }
927
2701
  const leftKeys = Object.keys(left);
928
2702
  const rightKeys = Object.keys(right);
929
2703
  if (leftKeys.length !== rightKeys.length) {
@@ -931,16 +2705,149 @@ function sameHashes(left, right) {
931
2705
  }
932
2706
  return leftKeys.every((key) => right[key] === left[key]);
933
2707
  }
2708
+ /**
2709
+ * Whether a project-walk snapshot is coherent for the inputs that matter.
2710
+ *
2711
+ * The walk reads every file under the project root, so a file nothing compiled
2712
+ * (a log being appended, a coverage report being written, a generated artifact
2713
+ * being replaced) can fail its own read sandwich while every input holds still.
2714
+ * That is not evidence about the generation, and treating it as such costs a
2715
+ * whole-project recompile per delivered module. A walk that could not enumerate
2716
+ * a directory, or a file-level failure this snapshot could not attribute to a
2717
+ * key, still taints everything: neither can be shown to leave the inputs
2718
+ * alone.
2719
+ */
2720
+ function walkSnapshotComplete(snapshot, declared) {
2721
+ if (declared === undefined) {
2722
+ return snapshot.complete;
2723
+ }
2724
+ if (!snapshot.directoryComplete) {
2725
+ return false;
2726
+ }
2727
+ for (const key of snapshot.unstableFiles) {
2728
+ if (declared.has(key))
2729
+ return false;
2730
+ }
2731
+ return true;
2732
+ }
2733
+ /** {@link selectDeclaredProjectInputKeys} memoized per envelope generation. */
2734
+ function declaredProjectInputKeys(state, cached) {
2735
+ if (state.declaredInputKeysBuilt !== true) {
2736
+ state.declaredInputKeys = selectDeclaredProjectInputKeys({
2737
+ identities: state.identityContext,
2738
+ projectRoot: cached.projectRoot,
2739
+ result: cached.result,
2740
+ });
2741
+ state.declaredInputKeysBuilt = true;
2742
+ }
2743
+ return state.declaredInputKeys;
2744
+ }
2745
+ /**
2746
+ * Project-walk keys of every input the envelope declares: the reference graph's
2747
+ * edge endpoints, globals, config chain, and resolution candidates, plus the
2748
+ * universal host inputs. Returns `undefined` for an envelope with no graph,
2749
+ * which declares no input set and therefore keeps whole-walk comparison.
2750
+ */
2751
+ function selectDeclaredProjectInputKeys(props) {
2752
+ if (props.result.type === "exception" || props.result.graph === undefined) {
2753
+ return undefined;
2754
+ }
2755
+ const graph = props.result.graph;
2756
+ const keys = new Set();
2757
+ const add = (entry) => {
2758
+ if (typeof entry !== "string" || entry.length === 0)
2759
+ return;
2760
+ keys.add(toProjectKey(props.projectRoot, path.resolve(props.projectRoot, entry), props.identities));
2761
+ };
2762
+ for (const [source, targets] of Object.entries(graph.edges ?? {})) {
2763
+ add(source);
2764
+ if (Array.isArray(targets))
2765
+ for (const target of targets)
2766
+ add(target);
2767
+ }
2768
+ if (Array.isArray(graph.globals))
2769
+ for (const input of graph.globals)
2770
+ add(input);
2771
+ if (Array.isArray(graph.configs))
2772
+ for (const input of graph.configs)
2773
+ add(input);
2774
+ for (const [source, candidates] of Object.entries(graph.candidates ?? {})) {
2775
+ add(source);
2776
+ if (Array.isArray(candidates))
2777
+ for (const entry of candidates)
2778
+ add(entry);
2779
+ }
2780
+ if (Array.isArray(props.result.hostInputs))
2781
+ for (const input of props.result.hostInputs)
2782
+ add(input);
2783
+ // Plugin-reported dependencies are inputs the graph never sees: a utility
2784
+ // plugin's own config file is consulted by the plugin, not by the compiler.
2785
+ for (const reported of Object.values(props.result.dependencies ?? {})) {
2786
+ if (Array.isArray(reported))
2787
+ for (const input of reported)
2788
+ add(input);
2789
+ }
2790
+ return keys;
2791
+ }
2792
+ /**
2793
+ * Project roots already told they cannot reuse a compile, so a build reports
2794
+ * the condition once instead of once per module.
2795
+ */
2796
+ const REPORTED_UNREUSABLE_GENERATIONS = new Set();
2797
+ /**
2798
+ * Report, once per project root, that a generation cannot be reused.
2799
+ *
2800
+ * Every module of the build then recompiles the whole project, so the condition
2801
+ * is the difference between one compile and one compile per module. It stayed
2802
+ * invisible for the whole life of samchon/ttsc#970: consumers saw only a build
2803
+ * that never finished, and each investigation had to rediscover the cause from
2804
+ * outside. A named reason turns the next occurrence into a bug report instead
2805
+ * of an archaeology session.
2806
+ */
2807
+ function reportUnreusableGeneration(cached, evidence) {
2808
+ const missing = [
2809
+ ...(evidence.walkStable ? [] : ["a stable project snapshot"]),
2810
+ ...(evidence.graphProofs ? [] : ["compiler proofs for its graph inputs"]),
2811
+ ...(evidence.externalInputs
2812
+ ? []
2813
+ : ["a complete out-of-walk input snapshot"]),
2814
+ ...(evidence.universalInputs ? [] : ["a universal host-input manifest"]),
2815
+ ];
2816
+ const key = `${cached.projectRoot}\0${missing.join(",")}`;
2817
+ if (REPORTED_UNREUSABLE_GENERATIONS.has(key)) {
2818
+ return;
2819
+ }
2820
+ REPORTED_UNREUSABLE_GENERATIONS.add(key);
2821
+ process.stderr.write(`ttsc: the transform cache cannot reuse this project's compile, so every ` +
2822
+ `module recompiles the whole project.\n` +
2823
+ ` project: ${cached.projectRoot}\n` +
2824
+ ` missing: ${missing.join("; ")}\n` +
2825
+ ` Please report this at https://github.com/samchon/ttsc/issues with ` +
2826
+ `this message.\n`);
2827
+ }
934
2828
  function hashText(input) {
935
2829
  return crypto.createHash("sha256").update(input).digest("hex");
936
2830
  }
937
2831
  async function transformProject(props) {
938
- const configured = createTransformTsconfig(props);
939
2832
  const projectRoot = path.dirname(props.tsconfig);
2833
+ const scratchDirectory = createTransformScratchDirectory(projectRoot, props.filesystem);
2834
+ let tracker;
2835
+ let retainTracker = false;
2836
+ let hostInputTracker;
2837
+ let candidateTracker;
2838
+ let retainHostInputTracker = false;
2839
+ let retainCandidateTracker = false;
940
2840
  try {
941
- const result = new TtscCompiler({
2841
+ const configured = createTransformTsconfig(props, scratchDirectory);
2842
+ const temporaryTsconfig = configured.path === props.tsconfig ? undefined : configured.path;
2843
+ const identities = createHostPathIdentityContext(props.filesystem);
2844
+ const before = collectProjectInputSnapshot(projectRoot, identities, props.filesystem);
2845
+ tracker = props.trackProjectMembership
2846
+ ? await createProjectMutationTracker(before.projectDirectories, props.filesystem)
2847
+ : undefined;
2848
+ const result = withTransformScratchEnvironment(scratchDirectory, () => new TtscCompiler({
942
2849
  cwd: projectRoot,
943
- // The generated tsconfig (if any) lives in the system temp directory,
2850
+ // The generated tsconfig (if any) lives outside the project directory,
944
2851
  // so declare the real project as the plugin config anchor: utility
945
2852
  // plugin config discovery (banner.config.*, strip.config.*,
946
2853
  // lint.config.*) and relative configFile resolution walk the project,
@@ -950,24 +2857,99 @@ async function transformProject(props) {
950
2857
  plugins: props.plugins,
951
2858
  projectRoot,
952
2859
  tsconfig: configured.path,
953
- }).transform();
954
- const temporaryTsconfig = configured.path === props.tsconfig ? undefined : configured.path;
2860
+ env: transformScratchEnvironment(scratchDirectory),
2861
+ }).transform());
2862
+ TRANSFORM_RESULT_FILESYSTEM.set(result, props.filesystem);
2863
+ // Mint the generation's clock reference after the compile and before any
2864
+ // signature-recording read below, so every input written before the
2865
+ // compile sits in a provably finished tick when its signature is captured.
2866
+ mintFilesystemClockReference(scratchDirectory, props.filesystem);
2867
+ const persistentHostInputs = selectPersistentHostInputs({
2868
+ filesystem: props.filesystem,
2869
+ projectRoot,
2870
+ result,
2871
+ temporaryTsconfig,
2872
+ });
2873
+ // The generation's absent resolution candidates, which get a watcher of
2874
+ // their own below; watching one is what lets a delivery stop probing it
2875
+ // (samchon/ttsc#1261). The validation manifest stays built from the
2876
+ // universal inputs alone, so nothing else about a candidate changes.
2877
+ //
2878
+ // Derived only where a tracker could carry it: a build-scoped adapter opens
2879
+ // no watcher, so probing every candidate's existence here would be work
2880
+ // whose answer nothing can read.
2881
+ const notifiableAbsence = props.trackProjectMembership
2882
+ ? selectNotifiableAbsentInputs({
2883
+ filesystem: props.filesystem,
2884
+ projectRoot,
2885
+ result,
2886
+ temporaryTsconfig,
2887
+ })
2888
+ : { candidates: [], watched: [] };
2889
+ hostInputTracker = props.trackProjectMembership
2890
+ ? await createHostInputMutationTracker(persistentHostInputs, props.filesystem,
2891
+ // A universal input never reaches the per-input loop that consults a
2892
+ // coverage claim: an absent one is proven by its directory listing
2893
+ // instead, which re-resolves the spelling every delivery.
2894
+ new Set())
2895
+ : undefined;
2896
+ // The candidates and the directories carrying them get their own tracker,
2897
+ // listening for renames alone. Every event that can make one of these
2898
+ // paths appear is a rename — the file itself, or a component of the path
2899
+ // being created, replaced, or retargeted — so nothing is given up, while a
2900
+ // backend that reports a write below a directory as a change to that
2901
+ // directory's entry (Windows does) would otherwise replace the generation
2902
+ // every time a bundler wrote inside `node_modules`.
2903
+ candidateTracker =
2904
+ notifiableAbsence.watched.length !== 0
2905
+ ? await createHostInputMutationTracker(notifiableAbsence.watched, props.filesystem, new Set(notifiableAbsence.candidates), "rename")
2906
+ : undefined;
955
2907
  const externalInputPaths = selectExternalInputPaths({
2908
+ filesystem: props.filesystem,
956
2909
  projectRoot,
957
2910
  result,
958
2911
  temporaryTsconfig,
959
2912
  });
960
- return {
2913
+ const inputSnapshot = collectProjectInputSnapshot(projectRoot, identities, props.filesystem);
2914
+ // Whether the recorded snapshot describes one coherent state of the
2915
+ // project. A membership event during the compile taints it exactly like an
2916
+ // unstable walk pair; whether notifications can be *opened* is a separate
2917
+ // fact, tracked below, because a generation with no watcher is still
2918
+ // provable from its own recorded state.
2919
+ const declaredInputs = selectDeclaredProjectInputKeys({
2920
+ identities,
2921
+ projectRoot,
2922
+ result,
2923
+ });
2924
+ const walkStable = walkSnapshotComplete(before, declaredInputs) &&
2925
+ walkSnapshotComplete(inputSnapshot, declaredInputs) &&
2926
+ sameHashes(before.hashes, inputSnapshot.hashes, declaredInputs) &&
2927
+ sameHashes(before.fileSignatures, inputSnapshot.fileSignatures, declaredInputs) &&
2928
+ sameProjectDirectories(before.projectDirectories, inputSnapshot.projectDirectories) &&
2929
+ tracker?.membershipChanged !== true &&
2930
+ hostInputTracker?.membershipChanged !== true &&
2931
+ candidateTracker?.membershipChanged !== true;
2932
+ const notificationsAvailable = tracker?.failed !== true &&
2933
+ hostInputTracker?.failed !== true &&
2934
+ candidateTracker?.failed !== true;
2935
+ // Overlay the in-memory source only after proving the two on-disk snapshots
2936
+ // stable; an unsaved editor buffer must not look like a compile-time race.
2937
+ const currentFileKey = toProjectKey(projectRoot, props.currentFile, identities);
2938
+ inputSnapshot.hashes[currentFileKey] = hashText(props.currentSource);
2939
+ // That overlay makes this one key the only recorded hash a disk signature
2940
+ // cannot stand for: the bytes it names came from the bundler, not the file.
2941
+ delete inputSnapshot.provenSignatures[currentFileKey];
2942
+ const cached = {
961
2943
  // Capture the out-of-walk input hashes while the generation is fresh so
962
2944
  // cache validation can re-check them; computed before dispose so the
963
2945
  // exclusion of the temp-dir tsconfig is the only reason it never keys.
964
- externalInputHashes: collectExternalInputHashes(externalInputPaths),
2946
+ externalInputHashes: {},
2947
+ externalInputRealpaths: {},
965
2948
  externalInputPaths,
966
- inputHashes: collectInputHashes({
967
- currentFile: props.currentFile,
968
- currentSource: props.currentSource,
969
- projectRoot,
970
- }),
2949
+ inputHashes: inputSnapshot.hashes,
2950
+ inputSignatures: inputSnapshot.provenSignatures,
2951
+ projectDirectories: inputSnapshot.projectDirectories,
2952
+ projectSnapshotComplete: false,
971
2953
  projectRoot,
972
2954
  result,
973
2955
  servedFiles: new Set(),
@@ -976,40 +2958,219 @@ async function transformProject(props) {
976
2958
  // but deleted file would invalidate every persistent-cache snapshot.
977
2959
  ...(temporaryTsconfig === undefined ? {} : { temporaryTsconfig }),
978
2960
  };
2961
+ const externalInputSnapshot = captureExternalInputSnapshot(cached, externalInputPaths);
2962
+ cached.externalInputHashes = externalInputSnapshot.hashes;
2963
+ cached.externalInputRealpaths = externalInputSnapshot.realpaths;
2964
+ cached.externalInputSignatures = externalInputSnapshot.signatures;
2965
+ // Evaluate every half, rather than short-circuiting, so a generation that
2966
+ // cannot be reused can say which evidence it lacked. The extra work runs
2967
+ // only on the failing path, where the alternative is recompiling the whole
2968
+ // project for every remaining module.
2969
+ const graphProofs = matchesCompilerGraphInputProofs(cached);
2970
+ const universalInputs = captureUniversalHostInputValidation(cached, props.currentFile) !==
2971
+ undefined;
2972
+ const stableProjectSnapshot = walkStable &&
2973
+ graphProofs &&
2974
+ externalInputSnapshot.complete &&
2975
+ universalInputs;
2976
+ // Only a caching host loses anything here: without a cache every delivery
2977
+ // compiles by design, so an unprovable generation costs it nothing.
2978
+ if (!stableProjectSnapshot && props.trackProjectMembership) {
2979
+ reportUnreusableGeneration(cached, {
2980
+ externalInputs: externalInputSnapshot.complete,
2981
+ graphProofs,
2982
+ universalInputs,
2983
+ walkStable,
2984
+ });
2985
+ }
2986
+ cached.projectSnapshotComplete = stableProjectSnapshot;
2987
+ // Attach notifications only while they can actually prove membership. A
2988
+ // generation that could not open its watchers keeps its recorded snapshot
2989
+ // and validates through it, rather than losing the cache entirely.
2990
+ const notifying = stableProjectSnapshot && notificationsAvailable;
2991
+ if (notifying && tracker !== undefined) {
2992
+ cached.projectMutationTracker = tracker;
2993
+ }
2994
+ if (notifying && hostInputTracker !== undefined) {
2995
+ cached.hostInputMutationTracker = hostInputTracker;
2996
+ }
2997
+ if (notifying && candidateTracker !== undefined) {
2998
+ cached.candidateMutationTracker = candidateTracker;
2999
+ }
3000
+ // Every tracker the generation published is retained, and every tracker it
3001
+ // did not is closed below. Naming only two of the three would close a
3002
+ // published candidate tracker the moment either of the others was absent,
3003
+ // and that is the one tracker whose silence is read as evidence.
3004
+ retainTracker = notifying && tracker !== undefined;
3005
+ retainHostInputTracker = notifying && hostInputTracker !== undefined;
3006
+ retainCandidateTracker = notifying && candidateTracker !== undefined;
3007
+ return cached;
979
3008
  }
980
3009
  finally {
981
- configured.dispose();
3010
+ try {
3011
+ if (!retainTracker && tracker !== undefined) {
3012
+ tracker.close();
3013
+ }
3014
+ }
3015
+ finally {
3016
+ try {
3017
+ if (!retainHostInputTracker && hostInputTracker !== undefined) {
3018
+ hostInputTracker.close();
3019
+ }
3020
+ }
3021
+ finally {
3022
+ try {
3023
+ if (!retainCandidateTracker && candidateTracker !== undefined) {
3024
+ candidateTracker.close();
3025
+ }
3026
+ }
3027
+ finally {
3028
+ fs.rmSync(scratchDirectory, { force: true, recursive: true });
3029
+ }
3030
+ }
3031
+ }
982
3032
  }
983
3033
  }
984
- function createTransformTsconfig(props) {
3034
+ /** Exclude the disposed overlay tsconfig from live host-input tracking. */
3035
+ function selectPersistentHostInputs(props) {
3036
+ if (props.result.type === "exception")
3037
+ return [];
3038
+ const inputs = selectListedFiles(props.projectRoot, props.result.hostInputs);
3039
+ if (props.temporaryTsconfig === undefined)
3040
+ return inputs;
3041
+ const identities = createHostPathIdentityContext(props.filesystem);
3042
+ const temporary = pathIdentityKey(props.temporaryTsconfig, identities);
3043
+ return inputs.filter((input) => pathIdentityKey(input, identities) !== temporary);
3044
+ }
3045
+ function createTransformTsconfig(props, scratchDirectory) {
985
3046
  const compilerOptions = normalizeCompilerOptionsForGeneratedTsconfig({
986
3047
  ...props.compilerOptions,
987
3048
  ...createAliasCompilerOptions(props),
988
3049
  }, path.dirname(props.tsconfig));
989
3050
  if (Object.keys(compilerOptions).length === 0) {
990
- return {
991
- path: props.tsconfig,
992
- dispose: () => undefined,
993
- };
3051
+ return { path: props.tsconfig };
994
3052
  }
995
- const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ttsc-unplugin-"));
996
- const file = path.join(directory, "tsconfig.json");
3053
+ const file = path.join(scratchDirectory, "tsconfig.json");
997
3054
  fs.writeFileSync(file, JSON.stringify({
998
3055
  extends: normalizePath(props.tsconfig),
999
3056
  compilerOptions,
1000
3057
  }, null, 2), "utf8");
3058
+ return { path: file };
3059
+ }
3060
+ /** Create compiler scratch storage outside the project snapshot and watchers. */
3061
+ function createTransformScratchDirectory(projectRoot, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
3062
+ const root = path.resolve(projectRoot);
3063
+ const canonicalRoot = filesystem.realpath(root);
3064
+ const platformTemp = process.platform === "win32" && process.env.LOCALAPPDATA
3065
+ ? path.join(process.env.LOCALAPPDATA, "Temp")
3066
+ : "/tmp";
3067
+ const candidates = [
3068
+ os.tmpdir(),
3069
+ platformTemp,
3070
+ path.dirname(root),
3071
+ os.homedir(),
3072
+ ];
3073
+ const canonicalCandidates = new Set();
3074
+ let failure;
3075
+ for (const candidate of new Set(candidates.map((dir) => path.resolve(dir)))) {
3076
+ if (pathIsWithin(candidate, root))
3077
+ continue;
3078
+ let canonicalCandidate;
3079
+ try {
3080
+ canonicalCandidate = filesystem.realpath(candidate);
3081
+ }
3082
+ catch (error) {
3083
+ failure = error;
3084
+ continue;
3085
+ }
3086
+ if (pathIsWithin(canonicalCandidate, canonicalRoot) ||
3087
+ canonicalCandidates.has(canonicalCandidate)) {
3088
+ continue;
3089
+ }
3090
+ canonicalCandidates.add(canonicalCandidate);
3091
+ let directory;
3092
+ try {
3093
+ directory = fs.mkdtempSync(path.join(canonicalCandidate, "ttsc-unplugin-"));
3094
+ }
3095
+ catch (error) {
3096
+ failure = error;
3097
+ continue;
3098
+ }
3099
+ let canonicalDirectory;
3100
+ try {
3101
+ canonicalDirectory = filesystem.realpath(directory);
3102
+ }
3103
+ catch (error) {
3104
+ try {
3105
+ fs.rmdirSync(directory);
3106
+ }
3107
+ catch (cleanupError) {
3108
+ throw cleanupError;
3109
+ }
3110
+ failure = error;
3111
+ continue;
3112
+ }
3113
+ // Use the postflight canonical spelling from this point onward. Returning
3114
+ // the candidate-relative spelling would let another process retarget its
3115
+ // parent symlink/junction after validation, redirecting compiler writes or
3116
+ // the final recursive removal into the project.
3117
+ if (!pathIsWithin(canonicalDirectory, canonicalRoot)) {
3118
+ return canonicalDirectory;
3119
+ }
3120
+ // Refuse the result and synchronously remove only our empty random child
3121
+ // through the identity that the postflight check just classified.
3122
+ fs.rmdirSync(canonicalDirectory);
3123
+ }
3124
+ throw (failure ??
3125
+ new Error("ttsc: no temporary directory exists outside the project"));
3126
+ }
3127
+ function pathIsWithin(child, parent) {
3128
+ const relative = path.relative(parent, child);
3129
+ return (relative === "" ||
3130
+ (relative !== ".." &&
3131
+ !relative.startsWith(`..${path.sep}`) &&
3132
+ !path.isAbsolute(relative)));
3133
+ }
3134
+ /** Route all compiler/plugin scratch to one owned directory outside project. */
3135
+ function transformScratchEnvironment(directory) {
1001
3136
  return {
1002
- path: file,
1003
- dispose: () => fs.rmSync(directory, { force: true, recursive: true }),
3137
+ ...process.env,
3138
+ TEMP: directory,
3139
+ TMP: directory,
3140
+ TMPDIR: directory,
1004
3141
  };
1005
3142
  }
3143
+ /** Scope parent-process temp consumers to the same owned scratch directory. */
3144
+ function withTransformScratchEnvironment(scratchDirectory, callback) {
3145
+ const environment = transformScratchEnvironment(scratchDirectory);
3146
+ const previous = {
3147
+ TEMP: process.env.TEMP,
3148
+ TMP: process.env.TMP,
3149
+ TMPDIR: process.env.TMPDIR,
3150
+ };
3151
+ process.env.TEMP = environment.TEMP;
3152
+ process.env.TMP = environment.TMP;
3153
+ process.env.TMPDIR = environment.TMPDIR;
3154
+ try {
3155
+ return callback();
3156
+ }
3157
+ finally {
3158
+ for (const [name, value] of Object.entries(previous)) {
3159
+ if (value === undefined)
3160
+ delete process.env[name];
3161
+ else
3162
+ process.env[name] = value;
3163
+ }
3164
+ }
3165
+ }
1006
3166
  /**
1007
3167
  * Resolve all relative paths inside `compilerOptions` against `tsconfigDir`.
1008
3168
  *
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.
3169
+ * The generated tsconfig lives in a temporary directory outside the project, so
3170
+ * any relative path (e.g. `"outDir": "../dist"`) that was meaningful relative
3171
+ * to the original tsconfig must be converted to an absolute path before writing
3172
+ * the generated file. Otherwise TypeScript-Go resolves it against the temp
3173
+ * dir.
1013
3174
  *
1014
3175
  * `paths` targets are absolutized for the same reason, with the extra twist
1015
3176
  * that TypeScript-Go rejects bare non-relative targets outright (TS5090) and
@@ -1282,7 +3443,7 @@ function formatUnknownError(error) {
1282
3443
  * compiler will error if that file does not exist, which is the correct
1283
3444
  * behavior for a mis-configured project.
1284
3445
  */
1285
- function resolveTsconfig(file, tsconfig) {
3446
+ function resolveTsconfig(file, tsconfig, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1286
3447
  if (tsconfig !== undefined) {
1287
3448
  return path.isAbsolute(tsconfig)
1288
3449
  ? tsconfig
@@ -1291,7 +3452,7 @@ function resolveTsconfig(file, tsconfig) {
1291
3452
  let current = path.dirname(file);
1292
3453
  while (true) {
1293
3454
  const candidate = path.join(current, "tsconfig.json");
1294
- if (fs.existsSync(candidate)) {
3455
+ if (filesystem.exists(candidate)) {
1295
3456
  return candidate;
1296
3457
  }
1297
3458
  const parent = path.dirname(current);
@@ -1323,5 +3484,5 @@ function normalizePath(file) {
1323
3484
  return file.replace(/\\/g, "/");
1324
3485
  }
1325
3486
 
1326
- export { beginTtscTransformBuild, collectExternalInputHashes, collectProjectInputHashes, createTransformResult, createTtscTransformCache, isDeclarationFile, isProjectWalkPath, pathIdentityKey, resetTtscTransformCache, stripQuery, transformTtsc };
3487
+ export { beginTtscTransformBuild, collectExternalInputHashes, collectProjectInputHashes, createTransformResult, createTtscTransformCache, isDeclarationFile, isProjectWalkPath, normalizeHostInputName, pathIdentityKey, resetTtscTransformCache, stripQuery, transformTtsc };
1327
3488
  //# sourceMappingURL=transform.mjs.map