@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 { type ChildProcess, spawn } from "node:child_process";
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import os from "node:os";
@@ -9,6 +10,7 @@ import type {
9
10
  import { TtscCompiler } from "ttsc";
10
11
  import {
11
12
  type FilesystemPathIdentityContext,
13
+ type FilesystemPathIdentityOperations,
12
14
  createFilesystemPathIdentityContext,
13
15
  } from "ttsc/path-identity";
14
16
  import type { TransformResult } from "unplugin";
@@ -43,15 +45,52 @@ export interface TtscTransformAlias {
43
45
  replacement: string;
44
46
  }
45
47
 
48
+ /** One directory's cheap project-membership identity at generation time. */
49
+ interface TtscProjectDirectorySnapshot {
50
+ /** Absolute directory spelling used by the project walk. */
51
+ path: string;
52
+ /** Metadata signature that changes when its immediate membership changes. */
53
+ signature: string;
54
+ }
55
+
56
+ /** Generation-scoped directory watchers used to detect membership changes. */
57
+ interface TtscProjectMutationTracker {
58
+ close: () => void;
59
+ /**
60
+ * Absolute spellings whose creation, change or removal this tracker would
61
+ * report, when it watches exact names rather than whole directories.
62
+ *
63
+ * A validation that finds an input here needs no filesystem call of its own:
64
+ * the tracker is the evidence, and every path that leaves this set falls back
65
+ * to being proven by hand. Empty for a tracker that watches directories as a
66
+ * whole, which cannot answer for one name.
67
+ */
68
+ covered?: ReadonlySet<string>;
69
+ /**
70
+ * Wait until every event this tracker's watcher has already dispatched has
71
+ * been applied to it.
72
+ *
73
+ * An in-process watcher drains on the next macrotask turn, because its
74
+ * callbacks are already queued on this loop. A watcher living in the Windows
75
+ * broker drains by round-trip instead: the child replies after its own turn,
76
+ * and IPC preserves order, so the reply cannot overtake an event the child
77
+ * had already sent (samchon/ttsc#1272).
78
+ */
79
+ drain?: () => Promise<void>;
80
+ failed: boolean;
81
+ membershipChanged: boolean;
82
+ settle?: Promise<void>;
83
+ }
84
+
46
85
  /**
47
86
  * A single entry in the project transform cache.
48
87
  *
49
88
  * Stores the full compiler result together with SHA-256 hashes of every project
50
89
  * input file. In a cache with an explicit build lifecycle, the first delivery
51
90
  * of each compiled module compares its supplied source with the generation
52
- * snapshot in constant time; a repeated delivery re-hashes the complete input
53
- * set. Persistent caches without that boundary perform complete validation on
54
- * every hit.
91
+ * snapshot in constant time. Later graph-bearing deliveries validate only the
92
+ * requested file's derived inputs plus exact host descriptor/config inputs;
93
+ * graph-free envelopes retain complete-snapshot validation.
55
94
  */
56
95
  export interface TtscCachedProjectTransform {
57
96
  /**
@@ -68,24 +107,95 @@ export interface TtscCachedProjectTransform {
68
107
  * `buildStart` and never replay across edits.
69
108
  */
70
109
  externalInputHashes?: Record<string, string>;
110
+ /**
111
+ * Compiler-time physical identities for graph-owned entries in
112
+ * {@link externalInputHashes}. Dependency-only paths have no generation
113
+ * realpath protocol and therefore omit this evidence.
114
+ */
115
+ externalInputRealpaths?: Record<string, string | null>;
71
116
  /**
72
117
  * Original absolute spellings of {@link externalInputHashes} inputs. These
73
118
  * stay separate from their identity keys so validation reads the paths the
74
119
  * compiler reported rather than a normalized replacement spelling.
75
120
  */
76
121
  externalInputPaths?: string[];
122
+ /**
123
+ * Metadata signature of each out-of-walk input, captured around the read that
124
+ * proved its {@link externalInputHashes} entry and recorded only once the
125
+ * observed filesystem's clock provably left the stamp's tick
126
+ * ({@link stampSeparable}). An input whose signature still holds carries the
127
+ * recorded content, so revalidation may skip the read.
128
+ *
129
+ * Keyed by lexical spelling rather than by physical identity, for the reason
130
+ * {@link TtscHostInputValidation} states: a symlink or junction spelling and
131
+ * its selected target deliberately share one identity but have different
132
+ * metadata, so an identity key would let the two overwrite each other's
133
+ * signature and force both to be re-read on every delivery.
134
+ */
135
+ externalInputSignatures?: Record<string, string>;
77
136
  /**
78
137
  * SHA-256 hash of each project-relative input path at the time of the
79
138
  * transform.
80
139
  */
81
140
  inputHashes: Record<string, string>;
141
+ /**
142
+ * Metadata signature of each {@link inputHashes} entry whose hash was proven
143
+ * against an unracing read of the file on disk, in a tick the observed
144
+ * filesystem's clock had provably left ({@link stampSeparable}).
145
+ *
146
+ * The generation's own current file is absent at capture: its recorded hash
147
+ * comes from the bundler's in-memory source, so the walk that produced it
148
+ * compared nothing. A later delivery of a sibling does compare that file's
149
+ * disk bytes against the recorded hash, and may record a signature then.
150
+ */
151
+ inputSignatures?: Record<string, string>;
152
+ /** Metadata snapshot of every directory in the stable generation walk. */
153
+ projectDirectories?: TtscProjectDirectorySnapshot[];
154
+ /** Live notification state for universal host-input changes. */
155
+ hostInputMutationTracker?: TtscProjectMutationTracker;
156
+ /**
157
+ * Live notification state for the generation's absent resolution candidates
158
+ * and the directories that carry them.
159
+ *
160
+ * Separate from the universal-input tracker because it listens for a
161
+ * different thing. Every event that can make an absent candidate present is a
162
+ * rename — the file appearing, a component of the path being created,
163
+ * replaced, or retargeted — so a change event on one of these names is never
164
+ * evidence this tracker exists to collect. What it is, on a backend that
165
+ * reports a write below a directory as a change to that directory's own entry
166
+ * (Windows does), is a dev server's steady traffic: listening for every event
167
+ * would replace the generation each time a bundler wrote inside
168
+ * `node_modules`. The filter therefore drops noise without dropping proof.
169
+ * The one appearance it cannot see is a Windows junction retargeted in place
170
+ * through `FSCTL_SET_REPARSE_POINT`, which no mainstream tool does; every
171
+ * package manager replaces the entry instead, which is a rename.
172
+ */
173
+ candidateMutationTracker?: TtscProjectMutationTracker;
174
+ /**
175
+ * Universal descriptor/config inputs proven once at generation time, then by
176
+ * metadata.
177
+ *
178
+ * Recorded state of the generation, like the input hashes and the directory
179
+ * snapshot beside it, rather than state derived from the envelope: an entry
180
+ * carries the manifest that proved it, so nothing can present one
181
+ * generation's recorded inputs under another envelope's proof.
182
+ */
183
+ hostInputValidation?: TtscHostInputValidation;
184
+ /** Live notification state for file/directory creation, deletion, and rename. */
185
+ projectMutationTracker?: TtscProjectMutationTracker;
186
+ /**
187
+ * Whether the generation-time project walk observed every directory and file
188
+ * it attempted to snapshot. An incomplete walk may never authorize narrow
189
+ * validation; a later complete walk must be allowed to replace it.
190
+ */
191
+ projectSnapshotComplete?: boolean;
82
192
  /** Absolute path to the directory that owns the tsconfig. */
83
193
  projectRoot: string;
84
194
  /** Raw compiler output returned by {@link TtscCompiler.transform}. */
85
195
  result: ITtscCompilerTransformation;
86
196
  /**
87
197
  * Files already delivered from this generation, keyed by filesystem identity.
88
- * Build-scoped caches use this to skip complete validation only for a
198
+ * Build-scoped caches use this to skip persistent validation only for a
89
199
  * module's first delivery inside the current build.
90
200
  */
91
201
  servedFiles?: Set<string>;
@@ -111,21 +221,132 @@ export type TtscTransformCache = Map<
111
221
  Promise<TtscCachedProjectTransform>
112
222
  >;
113
223
 
224
+ /** Cache-owned synchronous filesystem reads used by transform validation. */
225
+ export interface TtscTransformFilesystemOperations {
226
+ /** Override the case policy when the observed filesystem is not the host. */
227
+ caseSensitive?: FilesystemPathIdentityOperations["caseSensitive"];
228
+ /** Test whether a validation or resolution candidate currently exists. */
229
+ exists(location: string): boolean;
230
+ /** Read link metadata without following a symbolic link. */
231
+ lstat(location: string): fs.BigIntStats;
232
+ /** Read bytes used by project, graph, and host-input fingerprints. */
233
+ readFile(location: string): Buffer;
234
+ /** Enumerate one project or missing-input proof directory. */
235
+ readdir(location: string): fs.Dirent[];
236
+ /** Resolve one lexical path to its current physical target. */
237
+ realpath(location: string): string;
238
+ /** Read ordinary metadata for file-kind and missing-path checks. */
239
+ stat(location: string): fs.Stats;
240
+ /** Read nanosecond metadata for stable file and directory signatures. */
241
+ statBigInt(location: string): fs.BigIntStats;
242
+ /** Override path parsing when the observed filesystem is not the host. */
243
+ platform?: NodeJS.Platform;
244
+ /**
245
+ * Open one directory's change notification, or throw when the observed
246
+ * filesystem cannot provide one.
247
+ *
248
+ * Left undefined, generations watch the host filesystem: `fs.watch` on POSIX
249
+ * and an isolated broker process on Windows. An embedder observing another
250
+ * filesystem supplies its own; a generation whose watch cannot be opened
251
+ * keeps validating from recorded state instead of losing its cache.
252
+ *
253
+ * Supplying one replaces the Windows broker as well, so an embedder that
254
+ * wraps Node's own `fs.watch` there gives up the isolation that contains the
255
+ * native abort Node's Windows fs-event backend can raise when a watched
256
+ * temporary tree is deleted.
257
+ */
258
+ watch?(
259
+ directory: string,
260
+ listener: (eventType: string, filename: string | null) => void,
261
+ onError: () => void,
262
+ ): { close: () => void };
263
+ }
264
+
265
+ const DEFAULT_FILESYSTEM_OPERATIONS: TtscTransformFilesystemOperations =
266
+ Object.freeze({
267
+ exists: fs.existsSync,
268
+ lstat: (location: string) => fs.lstatSync(location, { bigint: true }),
269
+ readFile: (location: string) => fs.readFileSync(location),
270
+ readdir: (location: string) =>
271
+ fs.readdirSync(location, { withFileTypes: true }),
272
+ realpath: fs.realpathSync.native,
273
+ stat: fs.statSync,
274
+ statBigInt: (location: string) => fs.statSync(location, { bigint: true }),
275
+ });
276
+
277
+ const TRANSFORM_CACHE_FILESYSTEM = new WeakMap<
278
+ TtscTransformCache,
279
+ TtscTransformFilesystemOperations
280
+ >();
281
+ const TRANSFORM_RESULT_FILESYSTEM = new WeakMap<
282
+ ITtscCompilerTransformation,
283
+ TtscTransformFilesystemOperations
284
+ >();
285
+
114
286
  /**
115
287
  * Caches whose owner has declared a real per-build lifecycle by calling
116
288
  * {@link beginTtscTransformBuild} before transforms begin.
117
289
  */
118
290
  const BUILD_SCOPED_TRANSFORM_CACHES = new WeakSet<TtscTransformCache>();
119
291
 
120
- function createHostPathIdentityContext(): FilesystemPathIdentityContext {
292
+ function createHostPathIdentityContext(
293
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
294
+ ): FilesystemPathIdentityContext {
121
295
  return createFilesystemPathIdentityContext({
296
+ caseSensitive: filesystem.caseSensitive,
297
+ lstat: filesystem.lstat,
298
+ platform: filesystem.platform,
299
+ readdir: (directory) =>
300
+ filesystem.readdir(directory).map((entry) => entry.name),
301
+ realpath: filesystem.realpath,
122
302
  throwOnRealpathError: false,
123
303
  });
124
304
  }
125
305
 
126
- /** Create an empty persistent transform cache. */
127
- export function createTtscTransformCache(): TtscTransformCache {
128
- return new Map();
306
+ /** Normalize one directory entry under the owning filesystem's case policy. */
307
+ export function normalizeHostInputName(
308
+ name: string,
309
+ caseSensitive: boolean,
310
+ ): string {
311
+ return caseSensitive ? name : name.toLowerCase();
312
+ }
313
+
314
+ /** Create an empty persistent transform cache with isolated filesystem reads. */
315
+ export function createTtscTransformCache(
316
+ operations: Partial<TtscTransformFilesystemOperations> = {},
317
+ ): TtscTransformCache {
318
+ const cache: TtscTransformCache = new Map();
319
+ TRANSFORM_CACHE_FILESYSTEM.set(cache, {
320
+ caseSensitive: operations.caseSensitive,
321
+ exists: operations.exists ?? DEFAULT_FILESYSTEM_OPERATIONS.exists,
322
+ lstat: operations.lstat ?? DEFAULT_FILESYSTEM_OPERATIONS.lstat,
323
+ readFile: operations.readFile ?? DEFAULT_FILESYSTEM_OPERATIONS.readFile,
324
+ readdir: operations.readdir ?? DEFAULT_FILESYSTEM_OPERATIONS.readdir,
325
+ realpath: operations.realpath ?? DEFAULT_FILESYSTEM_OPERATIONS.realpath,
326
+ stat: operations.stat ?? DEFAULT_FILESYSTEM_OPERATIONS.stat,
327
+ statBigInt:
328
+ operations.statBigInt ?? DEFAULT_FILESYSTEM_OPERATIONS.statBigInt,
329
+ platform: operations.platform,
330
+ watch: operations.watch,
331
+ });
332
+ return cache;
333
+ }
334
+
335
+ function transformFilesystem(
336
+ cache: TtscTransformCache | undefined,
337
+ ): TtscTransformFilesystemOperations {
338
+ return (
339
+ (cache === undefined ? undefined : TRANSFORM_CACHE_FILESYSTEM.get(cache)) ??
340
+ DEFAULT_FILESYSTEM_OPERATIONS
341
+ );
342
+ }
343
+
344
+ function resultFilesystem(
345
+ result: ITtscCompilerTransformation,
346
+ ): TtscTransformFilesystemOperations {
347
+ return (
348
+ TRANSFORM_RESULT_FILESYSTEM.get(result) ?? DEFAULT_FILESYSTEM_OPERATIONS
349
+ );
129
350
  }
130
351
 
131
352
  /**
@@ -137,7 +358,7 @@ export function createTtscTransformCache(): TtscTransformCache {
137
358
  * defines one process-scoped module-loading session.
138
359
  */
139
360
  export function beginTtscTransformBuild(cache: TtscTransformCache): void {
140
- cache.clear();
361
+ clearTtscTransformCache(cache);
141
362
  BUILD_SCOPED_TRANSFORM_CACHES.add(cache);
142
363
  }
143
364
 
@@ -149,10 +370,37 @@ export function beginTtscTransformBuild(cache: TtscTransformCache): void {
149
370
  * many edits, so that callback cannot authorize build-scoped shortcuts.
150
371
  */
151
372
  export function resetTtscTransformCache(cache: TtscTransformCache): void {
152
- cache.clear();
373
+ clearTtscTransformCache(cache);
153
374
  BUILD_SCOPED_TRANSFORM_CACHES.delete(cache);
154
375
  }
155
376
 
377
+ /** Dispose generation-owned filesystem resources before clearing a cache. */
378
+ function clearTtscTransformCache(cache: TtscTransformCache): void {
379
+ const generations = [...cache.values()];
380
+ cache.clear();
381
+ for (const generation of generations) {
382
+ void generation.then(disposeCachedTransform, () => undefined);
383
+ }
384
+ }
385
+
386
+ /**
387
+ * What the generation already knows about one derived watch input, handed to
388
+ * the adapter so it does not rederive it per input per delivery.
389
+ *
390
+ * Both facts are generation state: the identity is the memoized
391
+ * {@link pathIdentityKey} of the input, and `missing` is the existence the
392
+ * generation recorded and every cache hit revalidates. An adapter that computes
393
+ * them itself pays a `realpath`, a case-sensitivity directory listing, and an
394
+ * `existsSync` for every input of every delivered module, which is O(modules x
395
+ * inputs) for one build (samchon/ttsc#1246).
396
+ */
397
+ export interface TtscWatchInputEvidence {
398
+ /** Memoized filesystem identity of the input. */
399
+ identity: string;
400
+ /** Whether the generation recorded this input as absent. */
401
+ missing: boolean;
402
+ }
403
+
156
404
  /**
157
405
  * Hooks the bundler adapter passes into {@link transformTtsc} so transform
158
406
  * side-channels (plugin-reported dependencies and host resolution candidates)
@@ -172,7 +420,7 @@ export interface TtscTransformHooks {
172
420
  * persistent-cache invalidation. See {@link selectWatchInputs} for the exact
173
421
  * derivation.
174
422
  */
175
- addWatchFile?: (file: string) => void;
423
+ addWatchFile?: (file: string, evidence?: TtscWatchInputEvidence) => void;
176
424
  /**
177
425
  * Invoked when the plugin declared the transformed file volatile (the
178
426
  * envelope's `volatile` list): its output depends on non-file inputs that no
@@ -215,6 +463,7 @@ export async function transformTtsc(
215
463
  cache?: TtscTransformCache,
216
464
  hooks?: TtscTransformHooks,
217
465
  ): Promise<TtscTransformResult | undefined> {
466
+ const filesystem = transformFilesystem(cache);
218
467
  const clean = stripQuery(id);
219
468
  if (clean.includes("\0")) {
220
469
  return undefined;
@@ -227,7 +476,7 @@ export async function transformTtsc(
227
476
  return undefined;
228
477
  }
229
478
 
230
- const tsconfig = resolveTsconfig(file, options.project);
479
+ const tsconfig = resolveTsconfig(file, options.project, filesystem);
231
480
  const aliasPaths = createAliasPaths(aliases);
232
481
  const key = createTransformCacheKey({
233
482
  aliasPaths,
@@ -242,11 +491,20 @@ export async function transformTtsc(
242
491
  // A rejected in-flight generation must not stay cached: evict it (only if
243
492
  // it is still the current entry) so a later call re-runs the transform.
244
493
  const cached = await awaitOrEvict(cache, key, transformed);
494
+ TRANSFORM_RESULT_FILESYSTEM.set(cached.result, filesystem);
245
495
  // While this caller awaited the old Promise, another caller may have
246
496
  // invalidated it and installed a newer authoritative generation.
247
497
  if (cache?.get(key) !== transformed) {
248
498
  continue;
249
499
  }
500
+ const buildScoped =
501
+ cache !== undefined && BUILD_SCOPED_TRANSFORM_CACHES.has(cache);
502
+ if (!buildScoped) {
503
+ await settleProjectMutationEvents(cached);
504
+ if (cache?.get(key) !== transformed) {
505
+ continue;
506
+ }
507
+ }
250
508
  if (
251
509
  // A file the plugin declared volatile must never be served from the
252
510
  // cache: its output depends on non-file inputs, so the input-hash
@@ -256,12 +514,7 @@ export async function transformTtsc(
256
514
  projectRoot: cached.projectRoot,
257
515
  result: cached.result,
258
516
  }) &&
259
- matchesCachedSource(
260
- cached,
261
- file,
262
- source,
263
- cache !== undefined && BUILD_SCOPED_TRANSFORM_CACHES.has(cache),
264
- )
517
+ matchesCachedSource(cached, file, source, buildScoped)
265
518
  ) {
266
519
  reportSuccessDiagnostics(cached.result);
267
520
  // A resolved `"exception"` / `"failure"` envelope makes this throw;
@@ -271,12 +524,7 @@ export async function transformTtsc(
271
524
  projectRoot: cached.projectRoot,
272
525
  result: cached.result,
273
526
  });
274
- notifyWatchInputs(hooks, {
275
- file,
276
- projectRoot: cached.projectRoot,
277
- result: cached.result,
278
- temporaryTsconfig: cached.temporaryTsconfig,
279
- });
527
+ notifyWatchInputs(hooks, cached, file);
280
528
  markCachedSourceServed(cached, file);
281
529
  return createTransformResult(source, code);
282
530
  }
@@ -296,7 +544,9 @@ export async function transformTtsc(
296
544
  compilerOptions: options.compilerOptions,
297
545
  currentFile: file,
298
546
  currentSource: source,
547
+ filesystem,
299
548
  plugins: options.plugins,
549
+ trackProjectMembership: cache !== undefined,
300
550
  tsconfig,
301
551
  });
302
552
  cache?.set(key, transformed);
@@ -306,14 +556,14 @@ export async function transformTtsc(
306
556
  if (cache !== undefined && cache.get(key) !== generation) {
307
557
  continue;
308
558
  }
309
- const { projectRoot, result, temporaryTsconfig } = cached;
559
+ const { projectRoot, result } = cached;
310
560
  reportSuccessDiagnostics(result);
311
561
  const code = selectOrEvict(cache, key, generation, {
312
562
  file,
313
563
  projectRoot,
314
564
  result,
315
565
  });
316
- notifyWatchInputs(hooks, { file, projectRoot, result, temporaryTsconfig });
566
+ notifyWatchInputs(hooks, cached, file);
317
567
  markCachedSourceServed(cached, file);
318
568
  if (
319
569
  isVolatileFile(envelopeDerivation(cached), { file, projectRoot, result })
@@ -383,9 +633,23 @@ function evictGeneration(
383
633
  ): void {
384
634
  if (cache?.get(key) === generation) {
385
635
  cache.delete(key);
636
+ void generation.then(disposeCachedTransform, () => undefined);
386
637
  }
387
638
  }
388
639
 
640
+ /** Close one generation's directory watchers exactly once. */
641
+ function disposeCachedTransform(cached: TtscCachedProjectTransform): void {
642
+ const trackers = [
643
+ cached.projectMutationTracker,
644
+ cached.hostInputMutationTracker,
645
+ cached.candidateMutationTracker,
646
+ ];
647
+ cached.projectMutationTracker = undefined;
648
+ cached.hostInputMutationTracker = undefined;
649
+ cached.candidateMutationTracker = undefined;
650
+ for (const tracker of trackers) tracker?.close();
651
+ }
652
+
389
653
  /**
390
654
  * Per-envelope derivation state: every index the per-delivery paths need, built
391
655
  * at most once and shared by all deliveries of one compiler result.
@@ -444,6 +708,53 @@ interface TtscEnvelopeDerivation {
444
708
  dependencyIndex?: Map<string, unknown>;
445
709
  /** Per-file memo of the final derived watch-input list. */
446
710
  readonly watchInputs: Map<string, string[]>;
711
+ /**
712
+ * Lazily built project-walk keys of the envelope's declared inputs, and
713
+ * whether that build already ran. A graph-free envelope declares no input
714
+ * set, so `undefined` after a completed build means "compare the whole walk";
715
+ * see {@link sameHashes}.
716
+ */
717
+ declaredInputKeys?: Set<string>;
718
+ declaredInputKeysBuilt?: boolean;
719
+ }
720
+
721
+ interface TtscHostInputValidation {
722
+ /** Lexical input spellings that existed when the generation was captured. */
723
+ readonly entries: Map<
724
+ string,
725
+ {
726
+ path: string;
727
+ /**
728
+ * Whether the recorded state of this input came from reading its bytes.
729
+ * An input that existed but could not be read records a missing state, so
730
+ * no signature may stand in for it: its metadata holds still while the
731
+ * bytes behind it appear.
732
+ */
733
+ readable: boolean;
734
+ realpath: string | null;
735
+ /**
736
+ * The signature that may stand in for this entry's content comparison, or
737
+ * `undefined` when none may. A blocker keeps one regardless: it proves a
738
+ * kind and an identity rather than content.
739
+ */
740
+ signature: string | undefined;
741
+ strict?: true;
742
+ }
743
+ >;
744
+ /**
745
+ * Lexical spellings the manifest accounts for, omitted from the per-module
746
+ * dependency loop below.
747
+ *
748
+ * Spellings, not identities: a symlink and its target share one identity but
749
+ * are two inputs, and skipping the alias because the manifest proved the
750
+ * target would leave the alias's own retarget unvalidated.
751
+ */
752
+ readonly covered: Set<string>;
753
+ /**
754
+ * Missing paths grouped by the nearest directory whose listing proves them
755
+ * absent.
756
+ */
757
+ readonly missing: Map<string, Set<string>>;
447
758
  }
448
759
 
449
760
  /** Reference-graph indexes shared by every watch-input derivation. */
@@ -457,6 +768,31 @@ interface TtscEnvelopeGraphIndexes {
457
768
  /** Resolved absolute `graph.globals` and `graph.configs` members. */
458
769
  readonly globals: string[];
459
770
  readonly configs: string[];
771
+ /** Every realized/candidate graph path, keyed by filesystem identity. */
772
+ readonly members: Set<string>;
773
+ /**
774
+ * Members the envelope reported only under `graph.candidates`, keyed by
775
+ * filesystem identity.
776
+ *
777
+ * A superseding candidate is by construction a path the compiler did not
778
+ * select, and usually one it never read at all: resolution stopped at the
779
+ * target that won, and the host enumerates the higher-priority spellings so
780
+ * that one appearing later can invalidate the generation. Such a path has no
781
+ * compile-time read to prove, so it carries the evidence a plugin-declared
782
+ * dependency path carries (the state recorded when the envelope was produced)
783
+ * instead of a compiler proof it can never have.
784
+ *
785
+ * A candidate that is also a realized input (an edge endpoint, a global, or a
786
+ * config) is absent from this set and keeps the realized standard.
787
+ */
788
+ readonly speculative: Set<string>;
789
+ /** Compiler-time proof for graph members, keyed by filesystem identity. */
790
+ readonly inputProofs: Map<
791
+ string,
792
+ { hash: string | null; path: string; realpath: string | null }
793
+ >;
794
+ /** Aliased graph proof keys that reported contradictory generation states. */
795
+ readonly inputProofConflicts: Set<string>;
460
796
  }
461
797
 
462
798
  /**
@@ -479,7 +815,9 @@ function envelopeDerivation(props: {
479
815
  return existing;
480
816
  }
481
817
  const created: TtscEnvelopeDerivation = {
482
- identityContext: createHostPathIdentityContext(),
818
+ identityContext: createHostPathIdentityContext(
819
+ resultFilesystem(props.result),
820
+ ),
483
821
  identities: new Map(),
484
822
  watchInputs: new Map(),
485
823
  };
@@ -508,6 +846,10 @@ function envelopeGraphIndexes(
508
846
  candidates: [],
509
847
  globals: [],
510
848
  configs: [],
849
+ members: new Set(),
850
+ speculative: new Set(),
851
+ inputProofs: new Map(),
852
+ inputProofConflicts: new Set(),
511
853
  };
512
854
  const graph =
513
855
  props.result.type === "exception" ? undefined : props.result.graph;
@@ -518,6 +860,7 @@ function envelopeGraphIndexes(
518
860
  }
519
861
  const absolute = path.resolve(props.projectRoot, source);
520
862
  const identity = derivationIdentity(state, absolute);
863
+ built.members.add(identity);
521
864
  built.spellings.set(identity, absolute);
522
865
  const entries = built.edges.get(identity) ?? [];
523
866
  entries.push(
@@ -526,16 +869,33 @@ function envelopeGraphIndexes(
526
869
  (target): target is string =>
527
870
  typeof target === "string" && target.length !== 0,
528
871
  )
529
- .map((target) => path.resolve(props.projectRoot, target)),
872
+ .map((target) => {
873
+ const absoluteTarget = path.resolve(props.projectRoot, target);
874
+ built.members.add(derivationIdentity(state, absoluteTarget));
875
+ return absoluteTarget;
876
+ }),
530
877
  );
531
878
  built.edges.set(identity, entries);
532
879
  }
533
880
  built.globals.push(...selectListedFiles(props.projectRoot, graph.globals));
534
881
  built.configs.push(...selectListedFiles(props.projectRoot, graph.configs));
535
- for (const [source, candidates] of Object.entries(graph.candidates ?? {})) {
536
- if (!Array.isArray(candidates)) {
537
- continue;
538
- }
882
+ for (const input of [...built.globals, ...built.configs]) {
883
+ built.members.add(derivationIdentity(state, input));
884
+ }
885
+ const candidateEntries = Object.entries(graph.candidates ?? {}).filter(
886
+ (entry) => Array.isArray(entry[1]),
887
+ );
888
+ // Every candidate source is an importing file the compiler read, so fold
889
+ // the sources in before classifying any candidate. Otherwise one entry's
890
+ // candidate could be classified speculative before a later entry proves
891
+ // the same path is a realized source.
892
+ for (const [source] of candidateEntries) {
893
+ built.members.add(
894
+ derivationIdentity(state, path.resolve(props.projectRoot, source)),
895
+ );
896
+ }
897
+ const realized = new Set(built.members);
898
+ for (const [source, candidates] of candidateEntries) {
539
899
  built.candidates.push({
540
900
  source: derivationIdentity(
541
901
  state,
@@ -543,6 +903,64 @@ function envelopeGraphIndexes(
543
903
  ),
544
904
  files: selectListedFiles(props.projectRoot, candidates),
545
905
  });
906
+ for (const candidate of candidates) {
907
+ if (typeof candidate !== "string" || candidate.length === 0) continue;
908
+ const identity = derivationIdentity(
909
+ state,
910
+ path.resolve(props.projectRoot, candidate),
911
+ );
912
+ // Edges, globals, configs, and every candidate source are folded in
913
+ // above, so a path absent from that set is one the envelope reported
914
+ // only as a candidate.
915
+ if (!realized.has(identity)) built.speculative.add(identity);
916
+ built.members.add(identity);
917
+ }
918
+ }
919
+ for (const [input, hash] of Object.entries(graph.inputHashes ?? {})) {
920
+ if (
921
+ hash !== null &&
922
+ (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash))
923
+ ) {
924
+ continue;
925
+ }
926
+ if (
927
+ graph.inputRealpaths === undefined ||
928
+ !Object.prototype.hasOwnProperty.call(graph.inputRealpaths, input)
929
+ ) {
930
+ continue;
931
+ }
932
+ const reportedRealpath = graph.inputRealpaths[input];
933
+ if (
934
+ reportedRealpath !== null &&
935
+ (typeof reportedRealpath !== "string" ||
936
+ !path.isAbsolute(reportedRealpath))
937
+ ) {
938
+ continue;
939
+ }
940
+ const absolute = path.resolve(props.projectRoot, input);
941
+ const identity = derivationIdentity(state, absolute);
942
+ if (!built.members.has(identity)) continue;
943
+ const proof = {
944
+ hash,
945
+ path: absolute,
946
+ realpath:
947
+ reportedRealpath === null ? null : path.resolve(reportedRealpath),
948
+ };
949
+ const previous = built.inputProofs.get(identity);
950
+ if (
951
+ previous !== undefined &&
952
+ (previous.hash !== proof.hash ||
953
+ !sameHostInputRealpath(
954
+ previous.realpath,
955
+ proof.realpath,
956
+ state.identityContext,
957
+ ))
958
+ ) {
959
+ built.inputProofs.delete(identity);
960
+ built.inputProofConflicts.add(identity);
961
+ } else if (!built.inputProofConflicts.has(identity)) {
962
+ built.inputProofs.set(identity, proof);
963
+ }
546
964
  }
547
965
  }
548
966
  state.graph = built;
@@ -605,19 +1023,31 @@ function collectDeclaredIdentities(
605
1023
  */
606
1024
  function notifyWatchInputs(
607
1025
  hooks: TtscTransformHooks | undefined,
608
- props: {
609
- file: string;
610
- projectRoot: string;
611
- result: ITtscCompilerTransformation;
612
- temporaryTsconfig?: string;
613
- },
1026
+ cached: TtscCachedProjectTransform,
1027
+ file: string,
614
1028
  ): void {
615
1029
  const addWatchFile = hooks?.addWatchFile;
616
1030
  if (addWatchFile === undefined) {
617
1031
  return;
618
1032
  }
619
- for (const input of selectWatchInputs(props)) {
620
- addWatchFile(input);
1033
+ const state = envelopeDerivation(cached);
1034
+ const external = cached.externalInputHashes ?? {};
1035
+ for (const input of selectWatchInputs({
1036
+ file,
1037
+ projectRoot: cached.projectRoot,
1038
+ result: cached.result,
1039
+ temporaryTsconfig: cached.temporaryTsconfig,
1040
+ })) {
1041
+ // Hand the adapter the identity this generation already resolved and the
1042
+ // existence state it already recorded. Both are memoized per generation,
1043
+ // while an adapter deriving them itself pays a `realpath`, a directory
1044
+ // listing, and an `existsSync` per input on every delivery of every module
1045
+ // (samchon/ttsc#1246).
1046
+ const identity = derivationIdentity(state, input);
1047
+ addWatchFile(input, {
1048
+ identity,
1049
+ missing: external[identity] === MISSING_INPUT_STATE,
1050
+ });
621
1051
  }
622
1052
  }
623
1053
 
@@ -677,31 +1107,65 @@ function deriveWatchInputs(
677
1107
  ): string[] {
678
1108
  const graph = envelopeGraphIndexes(state, props);
679
1109
  const output: string[] = [];
680
- const seen = new Set<string>();
1110
+ const physicalSeen = new Set<string>();
1111
+ const lexicalSeen = new Set<string>();
681
1112
  const excluded = new Set([fileIdentity]);
682
1113
  if (props.temporaryTsconfig !== undefined) {
683
1114
  excluded.add(derivationIdentity(state, props.temporaryTsconfig));
684
1115
  }
685
- for (const absolute of [
686
- ...selectFileDependencies(props),
687
- ...selectGraphInputs(graph, state, {
688
- ...props,
689
- complete:
690
- declaresCompleteDependencies(state, props) &&
691
- !isVolatileFile(state, props),
692
- }),
693
- ...selectResolutionCandidateInputs(graph, state, props),
694
- ]) {
695
- const identity = derivationIdentity(state, absolute);
696
- if (excluded.has(identity) || seen.has(identity)) {
697
- continue;
1116
+ const currentSpelling = path.resolve(props.file);
1117
+ const temporarySpelling =
1118
+ props.temporaryTsconfig === undefined
1119
+ ? undefined
1120
+ : path.resolve(props.temporaryTsconfig);
1121
+ const appendLexical = (input: string): void => {
1122
+ const spelling = path.resolve(input);
1123
+ if (
1124
+ spelling === currentSpelling ||
1125
+ spelling === temporarySpelling ||
1126
+ lexicalSeen.has(spelling)
1127
+ ) {
1128
+ return;
698
1129
  }
699
- seen.add(identity);
700
- output.push(absolute);
701
- }
1130
+ lexicalSeen.add(spelling);
1131
+ physicalSeen.add(derivationIdentity(state, input));
1132
+ output.push(input);
1133
+ };
1134
+ const appendPhysical = (input: string): void => {
1135
+ const identity = derivationIdentity(state, input);
1136
+ if (excluded.has(identity) || physicalSeen.has(identity)) return;
1137
+ physicalSeen.add(identity);
1138
+ lexicalSeen.add(path.resolve(input));
1139
+ output.push(input);
1140
+ };
1141
+ for (const input of selectFileDependencies(props)) appendLexical(input);
1142
+ for (const input of selectGraphInputs(graph, state, {
1143
+ ...props,
1144
+ complete:
1145
+ declaresCompleteDependencies(state, props) &&
1146
+ !isVolatileFile(state, props),
1147
+ }))
1148
+ appendPhysical(input);
1149
+ // Resolution candidates, plugin dependencies, and universal host inputs
1150
+ // preserve lexical aliases. Physical deduplication would collapse
1151
+ // `alias/selection.cjs` into the selected target path, so a bundler would
1152
+ // watch only the target and miss a symlink/junction retarget.
1153
+ for (const input of selectResolutionCandidateInputs(graph, state, props))
1154
+ appendLexical(input);
1155
+ for (const input of selectHostInputs(props)) appendLexical(input);
702
1156
  return output;
703
1157
  }
704
1158
 
1159
+ /** Return exact host-wide descriptor/config inputs for every output file. */
1160
+ function selectHostInputs(props: {
1161
+ projectRoot: string;
1162
+ result: ITtscCompilerTransformation;
1163
+ }): string[] {
1164
+ return props.result.type === "exception"
1165
+ ? []
1166
+ : selectListedFiles(props.projectRoot, props.result.hostInputs);
1167
+ }
1168
+
705
1169
  /**
706
1170
  * Return the module-resolution paths that can supersede a currently resolved
707
1171
  * module reachable from `file`. They remain host-owned even when a plugin
@@ -1039,14 +1503,13 @@ export function createTransformResult(
1039
1503
  *
1040
1504
  * Always compares the current module's in-memory source with the generation
1041
1505
  * snapshot. A cache whose owner called {@link beginTtscTransformBuild} can use
1042
- * that comparison alone for the module's first delivery in the current build;
1043
- * repeated requests re-hash every project and out-of-walk input. Persistent
1044
- * caches with no guaranteed build boundary perform complete validation on every
1045
- * hit. Any mismatch forces a complete re-transform.
1046
- *
1047
- * The complete validation snapshot and {@link collectInputHashes} draw their
1048
- * keys from the exact same {@link collectProjectInputHashes} walk, so the two
1049
- * agree on the key universe.
1506
+ * that comparison alone for a stable generation's first module delivery in the
1507
+ * current build. An incomplete generation may not take this shortcut: otherwise
1508
+ * a sibling output captured during a filesystem race could still be served
1509
+ * once. Later graph-bearing requests validate the file's derived input set and
1510
+ * project membership; graph-free envelopes conservatively re-hash the complete
1511
+ * project and out-of-walk snapshots. Any mismatch forces a complete
1512
+ * re-transform.
1050
1513
  */
1051
1514
  function matchesCachedSource(
1052
1515
  cached: TtscCachedProjectTransform,
@@ -1061,150 +1524,1840 @@ function matchesCachedSource(
1061
1524
  }
1062
1525
  if (
1063
1526
  buildScoped &&
1527
+ cached.projectSnapshotComplete === true &&
1064
1528
  !cached.servedFiles?.has(pathIdentityKey(file, identities))
1065
1529
  ) {
1066
1530
  return true;
1067
1531
  }
1068
- const currentHashes = collectProjectInputHashes(
1069
- cached.projectRoot,
1070
- identities,
1071
- );
1072
- currentHashes[currentKey] = hashText(source);
1073
- if (!sameHashes(cached.inputHashes, currentHashes)) {
1074
- return false;
1532
+ if (
1533
+ cached.result.type !== "exception" &&
1534
+ cached.result.graph !== undefined &&
1535
+ cached.projectSnapshotComplete === true &&
1536
+ cached.projectDirectories !== undefined &&
1537
+ cached.projectMutationTracker !== undefined &&
1538
+ cached.hostInputMutationTracker !== undefined
1539
+ ) {
1540
+ const narrow = matchesNarrowPersistentInputs(cached, file);
1541
+ if (narrow !== undefined) {
1542
+ return narrow;
1543
+ }
1544
+ // Notifications stopped proving membership after this generation was
1545
+ // produced. Losing the proof is not evidence of a change, so fall through
1546
+ // to the snapshot the entry still carries.
1075
1547
  }
1076
- // Re-hash the out-of-walk inputs the compiler reported for this generation
1077
- // over exactly the recorded key universe, so an edit to a `node_modules`
1078
- // declaration or a monorepo sibling source invalidates the entry even in a
1079
- // host that never clears the cache between builds. A new out-of-walk input
1080
- // cannot appear without some recorded input changing first: a new reference
1081
- // edge requires editing an in-walk source, and a new global or config file
1082
- // requires a tsconfig or package manifest change, both of which the project
1083
- // walk above already detects.
1084
- const externalHashes = cached.externalInputHashes ?? {};
1085
- return sameHashes(
1086
- externalHashes,
1087
- collectExternalInputHashes(
1088
- cached.externalInputPaths ?? Object.keys(externalHashes),
1089
- ),
1090
- );
1548
+ return matchesCompleteInputSnapshot(cached, currentKey, source);
1091
1549
  }
1092
1550
 
1093
- /** Record a successfully selected module as delivered by this generation. */
1094
- function markCachedSourceServed(
1551
+ /**
1552
+ * Validate one graph-bearing cached output against only the inputs that can
1553
+ * affect that file. Project membership is validated once per event-loop turn,
1554
+ * so sibling module deliveries share one directory-metadata pass instead of
1555
+ * multiplying it by module count.
1556
+ *
1557
+ * Returns `undefined` when this narrow proof is unavailable — live
1558
+ * notifications can no longer prove membership, or the generation carries no
1559
+ * universal-input manifest. That is the absence of a proof, not evidence of a
1560
+ * change, so the caller falls back to complete-snapshot validation instead of
1561
+ * discarding the generation. A reported membership event, a changed universal
1562
+ * input, or a changed derived input is evidence, and returns `false`.
1563
+ */
1564
+ function matchesNarrowPersistentInputs(
1095
1565
  cached: TtscCachedProjectTransform,
1096
1566
  file: string,
1097
- ): void {
1098
- (cached.servedFiles ??= new Set()).add(
1099
- pathIdentityKey(file, envelopeDerivation(cached).identityContext),
1100
- );
1567
+ ): boolean | undefined {
1568
+ if (reportsMembershipChange(cached)) {
1569
+ return false;
1570
+ }
1571
+ if (!notificationsProveMembership(cached)) {
1572
+ return undefined;
1573
+ }
1574
+ const state = envelopeDerivation(cached);
1575
+ const hostValidation = cached.hostInputValidation;
1576
+ if (hostValidation === undefined) {
1577
+ return undefined;
1578
+ }
1579
+ if (!matchesUniversalHostInputs(cached, hostValidation)) {
1580
+ return false;
1581
+ }
1582
+ const inputs = selectWatchInputs({
1583
+ file,
1584
+ projectRoot: cached.projectRoot,
1585
+ result: cached.result,
1586
+ temporaryTsconfig: cached.temporaryTsconfig,
1587
+ });
1588
+ for (const input of inputs) {
1589
+ // Skip by spelling, not identity: the manifest proved this exact path, and
1590
+ // an alias of the same physical file is a different input whose own
1591
+ // retarget nothing else would see.
1592
+ if (hostValidation.covered.has(path.resolve(input))) {
1593
+ continue;
1594
+ }
1595
+ if (!matchesProvenInput(cached, state, input)) {
1596
+ return false;
1597
+ }
1598
+ }
1599
+ return true;
1101
1600
  }
1102
1601
 
1103
1602
  /**
1104
- * Build the input-hash snapshot stored alongside a fresh compiler result.
1603
+ * Validate one derived input against the generation, skipping the content read
1604
+ * while the recorded metadata signature still holds.
1105
1605
  *
1106
- * Hashes every file under the project directory (the exact universe
1107
- * {@link matchesCachedSource} re-hashes to validate), then overlays the
1108
- * in-memory source for the module that triggered the compile so unsaved editor
1109
- * content is captured correctly.
1606
+ * Sibling deliveries of one generation share most of their derived inputs, and
1607
+ * `graph.globals` is shared by every one of them, so re-reading and re-hashing
1608
+ * the whole derived set per delivery multiplies one generation's proven bytes
1609
+ * by the module count. The derived set is proven the same way the universal
1610
+ * descriptor inputs are ({@link matchesUniversalHostInputs}), under the same
1611
+ * rules: an unchanged signature stands in for the content comparison, and any
1612
+ * signature change falls back to the full comparison. A signature is recorded
1613
+ * only around a read nothing raced, only for a recorded state that came from
1614
+ * reading the input rather than from failing to, and only while the observed
1615
+ * filesystem's own clock has provably left the stamp's tick
1616
+ * ({@link stampSeparable}), so a same-length rewrite inside that tick cannot
1617
+ * hide behind an unchanged signature.
1110
1618
  *
1111
- * Only the project's own files are hashed. Out-of-walk program inputs the
1112
- * compiler also read (`node_modules` declarations, sibling-package sources) are
1113
- * deliberately excluded: the validator never reproduces those keys, so keying
1114
- * them here would make every snapshot comparison fail and the cache never hit.
1619
+ * The signature carries the physical identity of both the lexical path and its
1620
+ * link target ({@link inputMetadataSignature}), so retargeting a symlink or
1621
+ * junction moves it and the skipped realpath comparison cannot be evaded.
1115
1622
  */
1116
- function collectInputHashes(props: {
1117
- currentFile: string;
1118
- currentSource: string;
1119
- projectRoot: string;
1120
- }): Record<string, string> {
1121
- const identities = createHostPathIdentityContext();
1122
- const hashes = collectProjectInputHashes(props.projectRoot, identities);
1123
- // Overlay the in-memory source so unsaved edits invalidate the cache.
1124
- hashes[toProjectKey(props.projectRoot, props.currentFile, identities)] =
1125
- hashText(props.currentSource);
1126
- return hashes;
1623
+ function matchesProvenInput(
1624
+ cached: TtscCachedProjectTransform,
1625
+ state: TtscEnvelopeDerivation,
1626
+ input: string,
1627
+ ): boolean {
1628
+ const slot = inputSignatureSlot(cached, state, input);
1629
+ if (slot === undefined) {
1630
+ return matchesRecordedInput(cached, input);
1631
+ }
1632
+ if (slot.recorded === MISSING_INPUT_STATE && notifiesAbsence(cached, input)) {
1633
+ // The generation's watcher holds this exact name, and the caller already
1634
+ // established that neither tracker failed and neither reported a change.
1635
+ // The path is therefore still absent, proven by the same channel that
1636
+ // proves project membership, and probing it again would only repeat what
1637
+ // the notification already answered.
1638
+ return true;
1639
+ }
1640
+ const filesystem = resultFilesystem(cached.result);
1641
+ const before = inputMetadataEvidence(input, filesystem);
1642
+ if (before !== undefined && slot.signatures[slot.key] === before.signature) {
1643
+ return true;
1644
+ }
1645
+ if (!matchesRecordedInput(cached, input)) {
1646
+ return false;
1647
+ }
1648
+ // A recorded `missing` state is the one comparison that succeeds without
1649
+ // reading anything: an unreadable path still reports `missing`, so its
1650
+ // metadata can hold still while the bytes behind it appear. Only content a
1651
+ // read produced may be stood for.
1652
+ const after =
1653
+ slot.recorded === MISSING_INPUT_STATE
1654
+ ? undefined
1655
+ : inputMetadataSignature(input, filesystem);
1656
+ if (after !== undefined && before?.signature === after && before.separable) {
1657
+ slot.signatures[slot.key] = after;
1658
+ } else {
1659
+ delete slot.signatures[slot.key];
1660
+ }
1661
+ return true;
1127
1662
  }
1128
1663
 
1129
1664
  /**
1130
- * Hash every input file under `projectRoot` (the same walk universe
1131
- * {@link matchesCachedSource} validates against), keyed by project-relative
1132
- * slash path. Exported so hosts without a per-build boundary (`@ttsc/metro`)
1133
- * can fold the identical input universe into their own cache fingerprints.
1665
+ * Report whether the generation's live watcher would announce a creation at
1666
+ * this absent input's exact spelling.
1667
+ *
1668
+ * Losing the watcher is not evidence of anything, so a failed tracker sends the
1669
+ * input back to being probed by hand, exactly as a failed tracker already sends
1670
+ * the whole generation back to complete-snapshot validation.
1134
1671
  */
1135
- export function collectProjectInputHashes(
1136
- projectRoot: string,
1137
- identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
1138
- ): Record<string, string> {
1139
- const hashes: Record<string, string> = {};
1140
- for (const file of listProjectInputFiles(projectRoot)) {
1141
- try {
1142
- hashes[toProjectKey(projectRoot, file, identities)] = hashText(
1143
- fs.readFileSync(file),
1144
- );
1145
- } catch {
1146
- // File watchers may observe a transform while another process is moving
1147
- // or deleting files. The missing key invalidates older cache entries.
1148
- }
1149
- }
1150
- return hashes;
1672
+ function notifiesAbsence(
1673
+ cached: TtscCachedProjectTransform,
1674
+ input: string,
1675
+ ): boolean {
1676
+ const tracker = cached.candidateMutationTracker;
1677
+ return (
1678
+ tracker !== undefined &&
1679
+ !tracker.failed &&
1680
+ tracker.covered?.has(path.resolve(input)) === true
1681
+ );
1151
1682
  }
1152
1683
 
1153
1684
  /**
1154
- * Enumerate every regular file under `root`, skipping well-known output and
1155
- * tooling directories (see {@link isIgnoredProjectDirectory}).
1685
+ * Locate the signature manifest that owns one recorded input, mirroring
1686
+ * {@link matchesRecordedInput}'s own preference for the out-of-walk spelling's
1687
+ * snapshot over the walked project's.
1156
1688
  *
1157
- * Uses an iterative DFS instead of `fs.readdirSync` recursion to avoid
1158
- * unbounded call-stack depth on deep project trees. The result is sorted so
1159
- * that hash comparisons are deterministic across OS-level directory orderings.
1689
+ * The manifest is returned whether or not it currently holds a signature for
1690
+ * the input, so a content comparison that succeeds can record one. Without
1691
+ * that, an input whose capture-time metadata was too recent to prove anything
1692
+ * would keep its content read for the whole life of the generation, since
1693
+ * nothing else ever revisits it. Returns `undefined` only for an input the
1694
+ * generation recorded no hash for, which no signature could stand for.
1160
1695
  */
1161
- function listProjectInputFiles(root: string): string[] {
1162
- const out: string[] = [];
1163
- const stack = [root];
1164
- while (stack.length !== 0) {
1165
- const current = stack.pop()!;
1166
- let entries: fs.Dirent[];
1167
- try {
1168
- entries = fs.readdirSync(current, { withFileTypes: true });
1169
- } catch {
1170
- continue;
1171
- }
1172
- for (const entry of entries) {
1173
- if (isIgnoredProjectDirectory(entry.name)) {
1174
- continue;
1175
- }
1176
- const file = path.join(current, entry.name);
1177
- if (entry.isDirectory()) {
1178
- stack.push(file);
1179
- } else if (entry.isFile()) {
1180
- out.push(file);
1181
- }
1182
- }
1696
+ function inputSignatureSlot(
1697
+ cached: TtscCachedProjectTransform,
1698
+ state: TtscEnvelopeDerivation,
1699
+ input: string,
1700
+ ):
1701
+ | { key: string; recorded: string; signatures: Record<string, string> }
1702
+ | undefined {
1703
+ const identity = derivationIdentity(state, input);
1704
+ const external = cached.externalInputHashes ?? {};
1705
+ if (Object.prototype.hasOwnProperty.call(external, identity)) {
1706
+ // The recorded hash is identity-keyed because aliases of one physical file
1707
+ // share its content; the signature is spelling-keyed because they do not
1708
+ // share its metadata.
1709
+ return {
1710
+ key: path.resolve(input),
1711
+ recorded: external[identity]!,
1712
+ signatures: (cached.externalInputSignatures ??= {}),
1713
+ };
1183
1714
  }
1184
- out.sort();
1185
- return out;
1186
- }
1187
-
1188
- /**
1715
+ const projectKey = toProjectKey(
1716
+ cached.projectRoot,
1717
+ input,
1718
+ state.identityContext,
1719
+ );
1720
+ return Object.prototype.hasOwnProperty.call(cached.inputHashes, projectKey)
1721
+ ? {
1722
+ key: projectKey,
1723
+ recorded: cached.inputHashes[projectKey]!,
1724
+ signatures: (cached.inputSignatures ??= {}),
1725
+ }
1726
+ : undefined;
1727
+ }
1728
+
1729
+ /**
1730
+ * Validate universal descriptor/config inputs without re-reading them for every
1731
+ * module. Existing paths use the same nanosecond metadata manifest that guards
1732
+ * GOROOT identity memoization; missing probes are grouped by the nearest
1733
+ * existing directory and checked through one exact membership listing.
1734
+ */
1735
+ function matchesUniversalHostInputs(
1736
+ cached: TtscCachedProjectTransform,
1737
+ validation: TtscHostInputValidation,
1738
+ ): boolean {
1739
+ return (
1740
+ matchesUniversalHostInputEntries(cached, validation) &&
1741
+ matchesUniversalHostInputProbes(cached, validation)
1742
+ );
1743
+ }
1744
+
1745
+ /**
1746
+ * Validate the universal inputs that exist, by metadata first and content only
1747
+ * when that moved.
1748
+ *
1749
+ * Every rejection here is evidence of a change — a vanished path, a moved
1750
+ * physical target, a strict blocker's metadata, differing content — so this
1751
+ * half is safe for a validation path that must never discard a generation for
1752
+ * want of a proof.
1753
+ */
1754
+ function matchesUniversalHostInputEntries(
1755
+ cached: TtscCachedProjectTransform,
1756
+ validation: TtscHostInputValidation,
1757
+ ): boolean {
1758
+ const filesystem = resultFilesystem(cached.result);
1759
+ for (const entry of validation.entries.values()) {
1760
+ const evidence = inputMetadataEvidence(entry.path, filesystem);
1761
+ if (
1762
+ entry.signature !== undefined &&
1763
+ evidence?.signature === entry.signature
1764
+ )
1765
+ continue;
1766
+ if (entry.strict === true) return false;
1767
+ if (hostInputRealpath(entry.path, filesystem) !== entry.realpath)
1768
+ return false;
1769
+ if (!matchesRecordedInput(cached, entry.path)) {
1770
+ return false;
1771
+ }
1772
+ if (evidence === undefined) return false;
1773
+ // Re-earn the proof under the rules the capture applies: an entry whose
1774
+ // recorded state came from reading nothing keeps its content comparison, a
1775
+ // write racing the read that just proved it records nothing, and a stamp
1776
+ // the filesystem's clock has not provably left records nothing either.
1777
+ const after = inputMetadataSignature(entry.path, filesystem);
1778
+ entry.signature =
1779
+ entry.readable && evidence.separable && after === evidence.signature
1780
+ ? evidence.signature
1781
+ : undefined;
1782
+ }
1783
+ return true;
1784
+ }
1785
+
1786
+ /**
1787
+ * Prove the universal inputs that were absent are still absent, through one
1788
+ * exact listing of the nearest directory that can settle it.
1789
+ *
1790
+ * Unlike the entries half, this one rejects on an inability to prove: a
1791
+ * directory that exists but cannot be listed certifies nothing about the
1792
+ * candidates inside it. That is the right answer for the narrow path, which has
1793
+ * no stronger proof to fall back to, but not for the whole-snapshot path, where
1794
+ * the recorded `missing` markers are re-compared directly and losing a proof
1795
+ * must not cost the cache.
1796
+ */
1797
+ function matchesUniversalHostInputProbes(
1798
+ cached: TtscCachedProjectTransform,
1799
+ validation: TtscHostInputValidation,
1800
+ ): boolean {
1801
+ const filesystem = resultFilesystem(cached.result);
1802
+ for (const [directory, names] of validation.missing) {
1803
+ let entries: fs.Dirent[];
1804
+ try {
1805
+ entries = filesystem.readdir(directory);
1806
+ } catch (error) {
1807
+ // Only a provably absent/non-directory ancestor keeps every descendant
1808
+ // unreachable. Permission and transient I/O failures cannot prove that
1809
+ // a candidate is still missing, while replacing the proving directory
1810
+ // with an exact file can itself redirect module resolution.
1811
+ try {
1812
+ if (!filesystem.stat(directory).isDirectory()) return false;
1813
+ } catch (statError) {
1814
+ if (!isMissingPathError(statError)) return false;
1815
+ continue;
1816
+ }
1817
+ return false;
1818
+ }
1819
+ const identities = envelopeDerivation(cached).identityContext;
1820
+ const caseSensitive = identities.caseSensitive(directory);
1821
+ if (
1822
+ entries.some((entry) =>
1823
+ names.has(normalizeHostInputName(entry.name, caseSensitive)),
1824
+ )
1825
+ ) {
1826
+ return false;
1827
+ }
1828
+ }
1829
+ return true;
1830
+ }
1831
+
1832
+ /** True only for errors that prove a path cannot currently be traversed. */
1833
+ function isMissingPathError(error: unknown): boolean {
1834
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
1835
+ return code === "ENOENT" || code === "ENOTDIR";
1836
+ }
1837
+
1838
+ /** Capture the universal-input manifest while the generation is still fresh. */
1839
+ function captureUniversalHostInputValidation(
1840
+ cached: TtscCachedProjectTransform,
1841
+ currentFile: string,
1842
+ ): TtscHostInputValidation | undefined {
1843
+ const filesystem = resultFilesystem(cached.result);
1844
+ const state = envelopeDerivation(cached);
1845
+ const validation: TtscHostInputValidation = {
1846
+ entries: new Map(),
1847
+ covered: new Set(),
1848
+ missing: new Map(),
1849
+ };
1850
+ for (const input of selectPersistentHostInputs({
1851
+ filesystem,
1852
+ projectRoot: cached.projectRoot,
1853
+ result: cached.result,
1854
+ temporaryTsconfig: cached.temporaryTsconfig,
1855
+ })) {
1856
+ const generationHashes =
1857
+ cached.result.type === "exception"
1858
+ ? undefined
1859
+ : cached.result.hostInputHashes;
1860
+ const generationRealpaths =
1861
+ cached.result.type === "exception"
1862
+ ? undefined
1863
+ : cached.result.hostInputRealpaths;
1864
+ const expected = generationHashes?.[path.resolve(input)];
1865
+ // Every persistent universal input must carry an evaluation-time
1866
+ // fingerprint. If a plugin/native host cannot provide one, keep the fresh
1867
+ // result but decline narrow long-lived reuse.
1868
+ let readable = false;
1869
+ if (expected === undefined) {
1870
+ const current = path.resolve(currentFile);
1871
+ if (path.resolve(input) !== current) return undefined;
1872
+ // The current module may be supplied from an unsaved editor buffer. Its
1873
+ // generation snapshot is overlaid below from `currentSource`, so a disk
1874
+ // fingerprint would be both unavailable and the wrong authority. The
1875
+ // recorded state is the bundler's, so a signature of the disk cannot
1876
+ // stand for it however readable that disk is.
1877
+ } else {
1878
+ const current = hostInputStateHash(input, filesystem);
1879
+ if (expected !== current) {
1880
+ return undefined;
1881
+ }
1882
+ // A path both sides agree they could not read carries no bytes for a
1883
+ // signature to stand for. It still belongs in the manifest, so the
1884
+ // content comparison keeps running for it on every delivery.
1885
+ readable = current !== null;
1886
+ }
1887
+ const absoluteInput = path.resolve(input);
1888
+ if (generationRealpaths !== undefined) {
1889
+ if (
1890
+ !Object.prototype.hasOwnProperty.call(
1891
+ generationRealpaths,
1892
+ absoluteInput,
1893
+ ) ||
1894
+ !sameHostInputRealpath(
1895
+ generationRealpaths[absoluteInput],
1896
+ hostInputRealpath(input, filesystem),
1897
+ state.identityContext,
1898
+ )
1899
+ ) {
1900
+ return undefined;
1901
+ }
1902
+ }
1903
+ validation.covered.add(path.resolve(input));
1904
+ const before = inputMetadataEvidence(input, filesystem);
1905
+ if (!matchesRecordedInput(cached, input)) return undefined;
1906
+ const after = inputMetadataSignature(input, filesystem);
1907
+ if (before?.signature !== after) return undefined;
1908
+ if (before !== undefined) {
1909
+ // Do not key this manifest by physical identity. A symlink/junction
1910
+ // spelling and its selected target deliberately share that identity,
1911
+ // but both lexical paths must survive so retargeting the alias is visible.
1912
+ validation.entries.set(path.resolve(input), {
1913
+ path: input,
1914
+ readable,
1915
+ realpath: hostInputRealpath(input, filesystem),
1916
+ // The signature stands in for content only when the read produced the
1917
+ // recorded bytes and the filesystem's clock has provably left the
1918
+ // stamp's tick; otherwise the content comparison keeps running until
1919
+ // the re-earn path can prove both.
1920
+ signature: readable && before.separable ? before.signature : undefined,
1921
+ });
1922
+ continue;
1923
+ }
1924
+ const probe = missingPathProbe(input, filesystem);
1925
+ if (probe.blocker !== undefined) {
1926
+ const signature = inputMetadataSignature(probe.blocker, filesystem);
1927
+ if (signature === undefined) return undefined;
1928
+ // A blocker proves a kind and an identity, not content: it is the
1929
+ // non-directory ancestor that makes everything below it unreachable, and
1930
+ // it cannot stop being that without its metadata moving. So it keeps a
1931
+ // usable signature whether or not anything read it, and exempt from the
1932
+ // clock-separability rule content signatures need — a same-tick rewrite
1933
+ // of its bytes leaves it exactly as blocking as before.
1934
+ validation.covered.add(path.resolve(probe.blocker));
1935
+ validation.entries.set(path.resolve(probe.blocker), {
1936
+ path: probe.blocker,
1937
+ readable: true,
1938
+ realpath: hostInputRealpath(probe.blocker, filesystem),
1939
+ signature,
1940
+ strict: true,
1941
+ });
1942
+ continue;
1943
+ }
1944
+ // The probe below proves this exact spelling absent, so the per-module loop
1945
+ // need not re-derive it either.
1946
+ let names = validation.missing.get(probe.directory);
1947
+ if (names === undefined) {
1948
+ names = new Set<string>();
1949
+ validation.missing.set(probe.directory, names);
1950
+ }
1951
+ names.add(
1952
+ normalizeHostInputName(
1953
+ probe.name,
1954
+ state.identityContext.caseSensitive(probe.directory),
1955
+ ),
1956
+ );
1957
+ }
1958
+ cached.hostInputValidation = validation;
1959
+ return validation;
1960
+ }
1961
+
1962
+ /**
1963
+ * The recorded state of an input the generation read nothing from: absent, or
1964
+ * present but unreadable. It is deliberately not a hash, so no signature may
1965
+ * stand in for it: the metadata of an unreadable path holds still while the
1966
+ * bytes behind it appear.
1967
+ *
1968
+ * A directory is not this state. It records the hash of a marker instead, which
1969
+ * a signature may stand for, because the mode both halves of the signature
1970
+ * carry cannot change without the path ceasing to be that directory.
1971
+ */
1972
+ const MISSING_INPUT_STATE = "missing";
1973
+
1974
+ /**
1975
+ * The highest stamp each observed filesystem clock has provably minted, keyed
1976
+ * by the operations object that observes it and, inside, by reporting device.
1977
+ *
1978
+ * A filesystem stamps a write once per clock tick, so two same-length writes
1979
+ * inside one tick are indistinguishable by metadata alone. A signature may
1980
+ * therefore stand for content only while a later write is guaranteed to move
1981
+ * it, and that guarantee needs a reference instant the observed filesystem
1982
+ * itself produced: once some stamp on the same device is strictly newer than an
1983
+ * input's modification stamp, that input's tick is provably over, so any later
1984
+ * write must mint a newer stamp and move the signature. That is git's
1985
+ * racily-clean index rule, adapted to a read-only contract: where git compares
1986
+ * entries against the index file's own timestamp, this floor accumulates every
1987
+ * stamp the cache-owned operations report, seeded per generation by
1988
+ * {@link mintFilesystemClockReference}.
1989
+ *
1990
+ * The process clock never participates: both sides of every comparison are
1991
+ * stamps the same filesystem clock minted, at the same granularity, so a
1992
+ * filesystem clock running behind (or ahead of) the host process changes
1993
+ * nothing.
1994
+ *
1995
+ * Accumulating observed stamps is deliberately weaker than git's own reference,
1996
+ * which is a single stamp git minted itself. A stamp this floor accepts may
1997
+ * instead have been _set_ rather than minted, and a set stamp is dangerous only
1998
+ * when it lands in the future: the floor is a maximum, so a restored past stamp
1999
+ * never raises it. One future-dated file — a stamp-preserving extraction or
2000
+ * copy from a machine whose clock ran ahead — pushes its device's floor past
2001
+ * the present and reopens the same-tick window for every other input on that
2002
+ * device until the clock catches up. A clock that jumps backwards strands the
2003
+ * floor above the present the same way, a different hazard from the constant
2004
+ * offset the paragraph above is about: an offset moves both operands together
2005
+ * and changes nothing, a jump moves only the present.
2006
+ *
2007
+ * The minted probe is not enough on its own to replace observed stamps: it
2008
+ * lands on the scratch volume, which is frequently not the inputs' volume (a
2009
+ * project on `D:` with `TEMP` on `C:`), and a probe-only floor would then
2010
+ * decline every _content_ signature, so every input carrying bytes would be
2011
+ * re-read on every delivery. A strict blocker keeps its signature either way,
2012
+ * because it proves a kind rather than content. Observed stamps keep the common
2013
+ * case working; the probe covers the case they cannot, a tree whose files were
2014
+ * all written inside one tick.
2015
+ */
2016
+ const FILESYSTEM_CLOCK_FLOORS = new WeakMap<
2017
+ TtscTransformFilesystemOperations,
2018
+ Map<bigint, bigint>
2019
+ >();
2020
+
2021
+ /** Return one observed filesystem's per-device clock floor, creating it. */
2022
+ function filesystemClockFloors(
2023
+ filesystem: TtscTransformFilesystemOperations,
2024
+ ): Map<bigint, bigint> {
2025
+ let floors = FILESYSTEM_CLOCK_FLOORS.get(filesystem);
2026
+ if (floors === undefined) {
2027
+ floors = new Map();
2028
+ FILESYSTEM_CLOCK_FLOORS.set(filesystem, floors);
2029
+ }
2030
+ return floors;
2031
+ }
2032
+
2033
+ /** Raise a device's clock floor with the stamps one observation reported. */
2034
+ function observeFilesystemClock(
2035
+ filesystem: TtscTransformFilesystemOperations,
2036
+ stats: fs.BigIntStats,
2037
+ ): void {
2038
+ const floors = filesystemClockFloors(filesystem);
2039
+ const stamp = stats.mtimeNs > stats.ctimeNs ? stats.mtimeNs : stats.ctimeNs;
2040
+ const current = floors.get(stats.dev);
2041
+ if (current === undefined || stamp > current) {
2042
+ floors.set(stats.dev, stamp);
2043
+ }
2044
+ }
2045
+
2046
+ /**
2047
+ * Report whether a later write to the observed path is guaranteed to move its
2048
+ * modification stamp: the device's clock floor holds a stamp strictly newer, so
2049
+ * the tick that minted the stamp is provably over. The floor was observed
2050
+ * before the caller's content read began, which is the ordering the guarantee
2051
+ * needs — a stamp minted before the read proves every post-read write lands in
2052
+ * a newer tick.
2053
+ */
2054
+ function stampSeparable(
2055
+ filesystem: TtscTransformFilesystemOperations,
2056
+ stats: fs.BigIntStats,
2057
+ ): boolean {
2058
+ const floor = filesystemClockFloors(filesystem).get(stats.dev);
2059
+ return floor !== undefined && stats.mtimeNs < floor;
2060
+ }
2061
+
2062
+ /**
2063
+ * Mint a reference instant for this generation and feed it into the observed
2064
+ * filesystem's clock floor.
2065
+ *
2066
+ * The scratch directory is a write the adapter already owns, deliberately
2067
+ * outside the project root, so stamping a probe file there produces a
2068
+ * freshly-minted "now" without touching the user's project — the analogue of
2069
+ * git writing its index. The probe is observed through the cache-owned
2070
+ * operations and keyed by the device those operations report, so it only ever
2071
+ * separates stamps on the filesystem that actually minted it; when the scratch
2072
+ * volume differs from the inputs' volume, or the observed filesystem cannot see
2073
+ * the probe at all, nothing is proven and signature recording simply stays
2074
+ * declined until passively observed stamps separate an input on their own.
2075
+ *
2076
+ * Relocating the scratch directory onto the inputs' volume would make the probe
2077
+ * universal, but it would also move every compiler and plugin temporary write
2078
+ * into the project's parent (frequently a monorepo root or a home directory)
2079
+ * for those layouts. That is a product decision about where ttsc writes, not a
2080
+ * property of this rule, so the cross-volume case degrades to more reads here
2081
+ * rather than being bought with it.
2082
+ */
2083
+ function mintFilesystemClockReference(
2084
+ scratchDirectory: string,
2085
+ filesystem: TtscTransformFilesystemOperations,
2086
+ ): void {
2087
+ try {
2088
+ const probe = path.join(scratchDirectory, "clock-reference");
2089
+ fs.writeFileSync(probe, "");
2090
+ observeFilesystemClock(filesystem, filesystem.lstat(probe));
2091
+ } catch {
2092
+ // The absence of a reference declines signature recording; it never
2093
+ // invalidates a generation.
2094
+ }
2095
+ }
2096
+
2097
+ /**
2098
+ * One metadata observation: the signature plus whether the observed filesystem
2099
+ * has provably moved past every write-mintable stamp inside it.
2100
+ */
2101
+ interface TtscInputMetadataEvidence {
2102
+ /** The joined metadata signature of the lexical path and its link target. */
2103
+ signature: string;
2104
+ /**
2105
+ * Whether a later write is guaranteed to move this signature. Only a
2106
+ * signature captured with this evidence may be recorded to stand in for a
2107
+ * content comparison; without it, a same-length rewrite inside the stamp's
2108
+ * own clock tick would leave the signature unchanged.
2109
+ */
2110
+ separable: boolean;
2111
+ }
2112
+
2113
+ /** Metadata identity whose stability lets a generation reuse a content hash. */
2114
+ function inputMetadataSignature(
2115
+ file: string,
2116
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
2117
+ ): string | undefined {
2118
+ return inputMetadataEvidence(file, filesystem)?.signature;
2119
+ }
2120
+
2121
+ /** Observe one input's metadata signature and its clock separability. */
2122
+ function inputMetadataEvidence(
2123
+ file: string,
2124
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
2125
+ ): TtscInputMetadataEvidence | undefined {
2126
+ try {
2127
+ const link = filesystem.lstat(file);
2128
+ observeFilesystemClock(filesystem, link);
2129
+ let target = link;
2130
+ if (link.isSymbolicLink()) {
2131
+ try {
2132
+ target = filesystem.statBigInt(file);
2133
+ observeFilesystemClock(filesystem, target);
2134
+ } catch {
2135
+ // Keep a broken link in the existing-input manifest. Its own metadata
2136
+ // stays stable while the target is missing, and the first successful
2137
+ // stat after the target appears changes this signature. Treating it as
2138
+ // a plain missing path would watch/list only the link's parent, which
2139
+ // cannot observe a target created in another directory. It carries no
2140
+ // readable bytes, so it never needs to be separable.
2141
+ return {
2142
+ signature: [
2143
+ link.dev,
2144
+ link.ino,
2145
+ link.mode,
2146
+ link.size,
2147
+ link.mtimeNs,
2148
+ link.ctimeNs,
2149
+ "missing-target",
2150
+ ].join(":"),
2151
+ separable: false,
2152
+ };
2153
+ }
2154
+ }
2155
+ return {
2156
+ signature: [
2157
+ link.dev,
2158
+ link.ino,
2159
+ link.mode,
2160
+ link.size,
2161
+ link.mtimeNs,
2162
+ link.ctimeNs,
2163
+ target.dev,
2164
+ target.ino,
2165
+ target.mode,
2166
+ target.size,
2167
+ target.mtimeNs,
2168
+ target.ctimeNs,
2169
+ ].join(":"),
2170
+ // Both halves must be separable: a write remints the target's stamp, a
2171
+ // link retarget the link's own, and either one hiding inside its recorded
2172
+ // tick would evade the skipped content and realpath comparisons.
2173
+ separable:
2174
+ stampSeparable(filesystem, link) && stampSeparable(filesystem, target),
2175
+ };
2176
+ } catch {
2177
+ return undefined;
2178
+ }
2179
+ }
2180
+
2181
+ /** Content/kind fingerprint matching the compiler host-input contract. */
2182
+ function hostInputStateHash(
2183
+ file: string,
2184
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
2185
+ ): string | null {
2186
+ try {
2187
+ return hashText(filesystem.readFile(file));
2188
+ } catch {
2189
+ try {
2190
+ return filesystem.stat(file).isDirectory()
2191
+ ? hashText("ttsc:host-input:directory\0")
2192
+ : null;
2193
+ } catch {
2194
+ return null;
2195
+ }
2196
+ }
2197
+ }
2198
+
2199
+ /** Fingerprint the text/kind state returned by TypeScript-Go's filesystem. */
2200
+ function graphInputStateHash(
2201
+ file: string,
2202
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
2203
+ ): string | null {
2204
+ try {
2205
+ const bytes = filesystem.readFile(file);
2206
+ if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
2207
+ const even = bytes.subarray(
2208
+ 2,
2209
+ 2 + Math.floor((bytes.length - 2) / 2) * 2,
2210
+ );
2211
+ return hashText(Buffer.from(even.toString("utf16le"), "utf8"));
2212
+ }
2213
+ if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
2214
+ const even = Buffer.from(
2215
+ bytes.subarray(2, 2 + Math.floor((bytes.length - 2) / 2) * 2),
2216
+ );
2217
+ even.swap16();
2218
+ return hashText(Buffer.from(even.toString("utf16le"), "utf8"));
2219
+ }
2220
+ const content =
2221
+ bytes.length >= 3 &&
2222
+ bytes[0] === 0xef &&
2223
+ bytes[1] === 0xbb &&
2224
+ bytes[2] === 0xbf
2225
+ ? bytes.subarray(3)
2226
+ : bytes;
2227
+ return hashText(content);
2228
+ } catch {
2229
+ try {
2230
+ return filesystem.stat(file).isDirectory()
2231
+ ? hashText("ttsc:host-input:directory\0")
2232
+ : null;
2233
+ } catch {
2234
+ return null;
2235
+ }
2236
+ }
2237
+ }
2238
+
2239
+ /** Physical target selected by a lexical host-input path. */
2240
+ function hostInputRealpath(
2241
+ file: string,
2242
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
2243
+ ): string | null {
2244
+ try {
2245
+ return filesystem.realpath(file);
2246
+ } catch {
2247
+ return null;
2248
+ }
2249
+ }
2250
+
2251
+ /** Compare two reported realpaths by filesystem identity, not Windows spelling. */
2252
+ function sameHostInputRealpath(
2253
+ left: string | null | undefined,
2254
+ right: string | null,
2255
+ identities: FilesystemPathIdentityContext,
2256
+ ): boolean {
2257
+ if (left === undefined || (left === null) !== (right === null)) return false;
2258
+ if (left === null || right === null) return true;
2259
+ return (
2260
+ pathIdentityKey(left, identities) === pathIdentityKey(right, identities)
2261
+ );
2262
+ }
2263
+
2264
+ /** Find one directory listing that proves an absent path is still absent. */
2265
+ function missingPathProbe(
2266
+ file: string,
2267
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
2268
+ ): {
2269
+ blocker?: string;
2270
+ directory: string;
2271
+ name: string;
2272
+ } {
2273
+ let child = path.resolve(file);
2274
+ for (;;) {
2275
+ const directory = path.dirname(child);
2276
+ try {
2277
+ const stats = filesystem.stat(directory);
2278
+ if (stats.isDirectory()) {
2279
+ return { directory, name: path.basename(child) };
2280
+ }
2281
+ return {
2282
+ blocker: directory,
2283
+ directory: path.dirname(directory),
2284
+ name: path.basename(directory),
2285
+ };
2286
+ } catch {}
2287
+ if (directory === child) {
2288
+ return { directory, name: path.basename(child) };
2289
+ }
2290
+ child = directory;
2291
+ }
2292
+ }
2293
+
2294
+ /**
2295
+ * Prove one generation from its own recorded snapshot, with no help from live
2296
+ * notifications.
2297
+ *
2298
+ * This is the fallback for a graph-free envelope and for a generation whose
2299
+ * watchers could not be opened or have since failed: losing the notification
2300
+ * proof must cost the narrow path, not the cache. The walk re-proves membership
2301
+ * directly — the recorded directory signatures plus the recorded file-key
2302
+ * universe — so a created, deleted, or renamed input still invalidates without
2303
+ * any watcher.
2304
+ */
2305
+ function matchesCompleteInputSnapshot(
2306
+ cached: TtscCachedProjectTransform,
2307
+ currentKey: string,
2308
+ source: string,
2309
+ ): boolean {
2310
+ if (
2311
+ cached.projectSnapshotComplete !== true ||
2312
+ cached.projectDirectories === undefined
2313
+ ) {
2314
+ return false;
2315
+ }
2316
+ // Universal descriptor/config inputs carry a physical-identity proof that no
2317
+ // content comparison can replace: retargeting a symlinked input to a
2318
+ // byte-identical file selects a different file, and its own transitive
2319
+ // requires with it. Only the graph half of the out-of-walk snapshot records
2320
+ // realpaths, so without this the fallback would quietly hold a lower standard
2321
+ // than the narrow path it stands in for.
2322
+ const state = envelopeDerivation(cached);
2323
+ const hostValidation = cached.hostInputValidation;
2324
+ if (
2325
+ hostValidation === undefined ||
2326
+ !matchesUniversalHostInputEntries(cached, hostValidation)
2327
+ ) {
2328
+ return false;
2329
+ }
2330
+ const declaredInputs = declaredProjectInputKeys(state, cached);
2331
+ const current = collectProjectInputSnapshot(
2332
+ cached.projectRoot,
2333
+ state.identityContext,
2334
+ resultFilesystem(cached.result),
2335
+ cached.inputSignatures === undefined
2336
+ ? undefined
2337
+ : { hashes: cached.inputHashes, signatures: cached.inputSignatures },
2338
+ );
2339
+ if (!walkSnapshotComplete(current, declaredInputs)) {
2340
+ return false;
2341
+ }
2342
+ if (
2343
+ !sameProjectDirectories(
2344
+ cached.projectDirectories,
2345
+ current.projectDirectories,
2346
+ )
2347
+ ) {
2348
+ return false;
2349
+ }
2350
+ current.hashes[currentKey] = hashText(source);
2351
+ if (!sameHashes(cached.inputHashes, current.hashes, declaredInputs)) {
2352
+ return false;
2353
+ }
2354
+ // Re-hash the out-of-walk inputs the compiler reported for this generation
2355
+ // over exactly the recorded key universe, so an edit to a `node_modules`
2356
+ // declaration or a monorepo sibling source invalidates the entry even in a
2357
+ // host that never clears the cache between builds. A new out-of-walk input
2358
+ // cannot appear without some recorded input changing first: a new reference
2359
+ // edge requires editing an in-walk source, and a new global or config file
2360
+ // requires a tsconfig or package manifest change, both of which the project
2361
+ // walk above already detects.
2362
+ const externalCurrent = matchesCachedExternalInputs(cached);
2363
+ if (!externalCurrent.matches || !matchesExternalInputRealpaths(cached)) {
2364
+ return false;
2365
+ }
2366
+ adoptProvenSignatures(cached, {
2367
+ currentKey,
2368
+ external: externalCurrent.signatures,
2369
+ project: current.provenSignatures,
2370
+ });
2371
+ return true;
2372
+ }
2373
+
2374
+ /**
2375
+ * Adopt the signatures captured while this walk proved every recorded input
2376
+ * still carries its recorded content.
2377
+ *
2378
+ * Without this, a metadata-only change — a touch, or a rewrite of identical
2379
+ * bytes — costs a re-read on every later delivery for the rest of the
2380
+ * generation's life, because the recorded signature can never match again. The
2381
+ * narrow path self-heals through {@link matchesProvenInput}; this is the same
2382
+ * refresh for the path that proves the whole snapshot at once.
2383
+ *
2384
+ * The delivered file is the single exclusion: its recorded hash is the source
2385
+ * the bundler supplied, so the disk bytes this walk read for it were compared
2386
+ * against nothing.
2387
+ */
2388
+ function adoptProvenSignatures(
2389
+ cached: TtscCachedProjectTransform,
2390
+ proven: {
2391
+ currentKey: string;
2392
+ external: Record<string, string>;
2393
+ project: Record<string, string>;
2394
+ },
2395
+ ): void {
2396
+ const projectSignatures = (cached.inputSignatures ??= {});
2397
+ for (const [key, signature] of Object.entries(proven.project)) {
2398
+ if (key === proven.currentKey) continue;
2399
+ projectSignatures[key] = signature;
2400
+ }
2401
+ const externalSignatures = (cached.externalInputSignatures ??= {});
2402
+ for (const [spelling, signature] of Object.entries(proven.external)) {
2403
+ externalSignatures[spelling] = signature;
2404
+ }
2405
+ }
2406
+
2407
+ /** Re-check graph-owned physical identities in complete-snapshot fallback. */
2408
+ function matchesExternalInputRealpaths(
2409
+ cached: TtscCachedProjectTransform,
2410
+ ): boolean {
2411
+ const expected = cached.externalInputRealpaths;
2412
+ if (expected === undefined || Object.keys(expected).length === 0) return true;
2413
+ const state = envelopeDerivation(cached);
2414
+ const filesystem = resultFilesystem(cached.result);
2415
+ for (const input of cached.externalInputPaths ?? []) {
2416
+ const identity = derivationIdentity(state, input);
2417
+ if (!Object.prototype.hasOwnProperty.call(expected, identity)) continue;
2418
+ if (
2419
+ !sameHostInputRealpath(
2420
+ expected[identity],
2421
+ hostInputRealpath(input, filesystem),
2422
+ state.identityContext,
2423
+ )
2424
+ ) {
2425
+ return false;
2426
+ }
2427
+ }
2428
+ return true;
2429
+ }
2430
+
2431
+ /**
2432
+ * Capture external-input hashes without attaching post-compile state to an
2433
+ * earlier graph. Graph members must carry compiler-time proof and still match
2434
+ * it now; plugin-declared dependency-only paths retain the historical
2435
+ * post-compile snapshot because their own protocol does not claim generation
2436
+ * fingerprints.
2437
+ */
2438
+ function captureExternalInputSnapshot(
2439
+ cached: TtscCachedProjectTransform,
2440
+ paths: readonly string[],
2441
+ ): {
2442
+ complete: boolean;
2443
+ hashes: Record<string, string>;
2444
+ realpaths: Record<string, string | null>;
2445
+ signatures: Record<string, string>;
2446
+ } {
2447
+ const state = envelopeDerivation(cached);
2448
+ const filesystem = resultFilesystem(cached.result);
2449
+ const graph = envelopeGraphIndexes(state, cached);
2450
+ const hashes: Record<string, string> = {};
2451
+ const realpaths: Record<string, string | null> = {};
2452
+ const signatures: Record<string, string> = {};
2453
+ let complete = true;
2454
+ // Sandwich every read between two metadata signatures. Only a signature that
2455
+ // survived its own read, and whose stamp's tick the filesystem's clock has
2456
+ // provably left ({@link stampSeparable}), may stand in for the content
2457
+ // comparison; a write racing the capture, or a stamp a same-tick rewrite
2458
+ // could still reproduce, leaves the input without one, so revalidation keeps
2459
+ // re-reading it.
2460
+ const record = (
2461
+ input: string,
2462
+ before: TtscInputMetadataEvidence | undefined,
2463
+ after: string | undefined,
2464
+ ): void => {
2465
+ if (after !== undefined && before?.signature === after && before.separable)
2466
+ signatures[path.resolve(input)] = after;
2467
+ };
2468
+ for (const input of paths) {
2469
+ const identity = derivationIdentity(state, input);
2470
+ // A member the envelope reported only as a resolution candidate falls
2471
+ // through to the recorded-state branch below, the same evidence a
2472
+ // plugin-declared dependency path carries. Its absence still invalidates
2473
+ // the generation when it appears, because `missing` is recorded state.
2474
+ const speculativeOnly =
2475
+ graph.speculative.has(identity) &&
2476
+ !graph.inputProofs.has(identity) &&
2477
+ !graph.inputProofConflicts.has(identity);
2478
+ if (graph.members.has(identity) && !speculativeOnly) {
2479
+ const proof = graph.inputProofs.get(identity);
2480
+ if (proof === undefined || graph.inputProofConflicts.has(identity)) {
2481
+ complete = false;
2482
+ continue;
2483
+ }
2484
+ const before = inputMetadataEvidence(input, filesystem);
2485
+ const currentHash = graphInputStateHash(input, filesystem);
2486
+ const after = inputMetadataSignature(input, filesystem);
2487
+ if (
2488
+ currentHash !== proof.hash ||
2489
+ !sameHostInputRealpath(
2490
+ proof.realpath,
2491
+ hostInputRealpath(input, filesystem),
2492
+ state.identityContext,
2493
+ )
2494
+ ) {
2495
+ complete = false;
2496
+ } else if (currentHash !== null) {
2497
+ // The recorded hash is the compiler's own proof, so a signature may
2498
+ // only stand for it once the current bytes were shown to match it.
2499
+ // A path with no readable content has no bytes to stand for: it can
2500
+ // hold stable metadata while becoming readable, so it keeps the read.
2501
+ record(input, before, after);
2502
+ }
2503
+ hashes[identity] = proof.hash ?? MISSING_INPUT_STATE;
2504
+ realpaths[identity] = proof.realpath;
2505
+ continue;
2506
+ }
2507
+ const before = inputMetadataEvidence(input, filesystem);
2508
+ const hash = hostInputStateHash(input, filesystem);
2509
+ const after = inputMetadataSignature(input, filesystem);
2510
+ hashes[identity] = hash ?? MISSING_INPUT_STATE;
2511
+ if (hash !== null) record(input, before, after);
2512
+ }
2513
+ return { complete, hashes, realpaths, signatures };
2514
+ }
2515
+
2516
+ /** Verify every graph member still has the state read by the compiler. */
2517
+ function matchesCompilerGraphInputProofs(
2518
+ cached: TtscCachedProjectTransform,
2519
+ ): boolean {
2520
+ if (
2521
+ cached.result.type === "exception" ||
2522
+ cached.result.graph === undefined ||
2523
+ (cached.result.graph.inputHashes === undefined &&
2524
+ cached.result.graph.inputRealpaths === undefined)
2525
+ ) {
2526
+ // Legacy sidecars remain compatible for ordinary in-project graphs. Their
2527
+ // out-of-walk members are still rejected by captureExternalInputSnapshot,
2528
+ // where a post-compile snapshot cannot prove the compiler's generation.
2529
+ return true;
2530
+ }
2531
+ const state = envelopeDerivation(cached);
2532
+ const filesystem = resultFilesystem(cached.result);
2533
+ const graph = envelopeGraphIndexes(state, cached);
2534
+ if (graph.inputProofConflicts.size !== 0) {
2535
+ return false;
2536
+ }
2537
+ for (const identity of graph.members) {
2538
+ const proof = graph.inputProofs.get(identity);
2539
+ // A speculative candidate has no compile-time read to prove. Requiring one
2540
+ // would void every generation of every project whose resolution passes over
2541
+ // a higher-priority spelling, which is every project with a dependency
2542
+ // typed by a declaration file (samchon/ttsc#1245). It is validated instead
2543
+ // against the state {@link captureExternalInputSnapshot} recorded for it.
2544
+ if (proof === undefined && graph.speculative.has(identity)) {
2545
+ continue;
2546
+ }
2547
+ if (
2548
+ proof === undefined ||
2549
+ graphInputStateHash(proof.path, filesystem) !== proof.hash ||
2550
+ !sameHostInputRealpath(
2551
+ proof.realpath,
2552
+ hostInputRealpath(proof.path, filesystem),
2553
+ state.identityContext,
2554
+ )
2555
+ ) {
2556
+ return false;
2557
+ }
2558
+ }
2559
+ return true;
2560
+ }
2561
+
2562
+ /** Compare one derived input with the snapshot that owned it at generation. */
2563
+ function matchesRecordedInput(
2564
+ cached: TtscCachedProjectTransform,
2565
+ input: string,
2566
+ ): boolean {
2567
+ const state = envelopeDerivation(cached);
2568
+ const filesystem = resultFilesystem(cached.result);
2569
+ const projectKey = toProjectKey(
2570
+ cached.projectRoot,
2571
+ input,
2572
+ state.identityContext,
2573
+ );
2574
+ const projectHash = Object.prototype.hasOwnProperty.call(
2575
+ cached.inputHashes,
2576
+ projectKey,
2577
+ )
2578
+ ? cached.inputHashes[projectKey]
2579
+ : undefined;
2580
+ const identity = derivationIdentity(state, input);
2581
+ const externalHash = (cached.externalInputHashes ?? {})[identity];
2582
+ const externalRealpaths = cached.externalInputRealpaths;
2583
+ const graphInput =
2584
+ externalRealpaths !== undefined &&
2585
+ Object.prototype.hasOwnProperty.call(externalRealpaths, identity);
2586
+ if (
2587
+ externalRealpaths !== undefined &&
2588
+ Object.prototype.hasOwnProperty.call(externalRealpaths, identity) &&
2589
+ !sameHostInputRealpath(
2590
+ externalRealpaths[identity],
2591
+ hostInputRealpath(input, filesystem),
2592
+ state.identityContext,
2593
+ )
2594
+ ) {
2595
+ return false;
2596
+ }
2597
+ // Prefer the out-of-walk spelling's own snapshot when it exists. A lexical
2598
+ // alias can point back into the walked project, where the physical target's
2599
+ // project hash is a different authority (and graph text uses BOM decoding).
2600
+ const recorded = externalHash ?? projectHash;
2601
+ if (recorded === undefined) {
2602
+ return false;
2603
+ }
2604
+ try {
2605
+ const current = graphInput
2606
+ ? graphInputStateHash(input, filesystem)
2607
+ : hostInputStateHash(input, filesystem);
2608
+ return recorded === (current ?? MISSING_INPUT_STATE);
2609
+ } catch {
2610
+ return recorded === MISSING_INPUT_STATE;
2611
+ }
2612
+ }
2613
+
2614
+ /** Record a successfully selected module as delivered by this generation. */
2615
+ function markCachedSourceServed(
2616
+ cached: TtscCachedProjectTransform,
2617
+ file: string,
2618
+ ): void {
2619
+ (cached.servedFiles ??= new Set()).add(
2620
+ pathIdentityKey(file, envelopeDerivation(cached).identityContext),
2621
+ );
2622
+ }
2623
+
2624
+ /**
2625
+ * Hash every input file under `projectRoot` (the same walk universe
2626
+ * {@link matchesCachedSource} validates against), keyed by project-relative
2627
+ * slash path. Exported so hosts without a per-build boundary (`@ttsc/metro`)
2628
+ * can fold the identical input universe into their own cache fingerprints.
2629
+ */
2630
+ export function collectProjectInputHashes(
2631
+ projectRoot: string,
2632
+ identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
2633
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
2634
+ ): Record<string, string> {
2635
+ return collectProjectInputSnapshot(projectRoot, identities, filesystem)
2636
+ .hashes;
2637
+ }
2638
+
2639
+ /** Hash project files and snapshot the directory topology in one walk. */
2640
+ function collectProjectInputSnapshot(
2641
+ projectRoot: string,
2642
+ identities: FilesystemPathIdentityContext,
2643
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
2644
+ proven?: {
2645
+ hashes: Record<string, string>;
2646
+ signatures: Record<string, string>;
2647
+ },
2648
+ ): {
2649
+ complete: boolean;
2650
+ directoryComplete: boolean;
2651
+ fileSignatures: Record<string, string>;
2652
+ hashes: Record<string, string>;
2653
+ projectDirectories: TtscProjectDirectorySnapshot[];
2654
+ provenSignatures: Record<string, string>;
2655
+ unstableFiles: Set<string>;
2656
+ } {
2657
+ const hashes: Record<string, string> = {};
2658
+ const fileSignatures: Record<string, string> = {};
2659
+ const provenSignatures: Record<string, string> = {};
2660
+ const unstableFiles = new Set<string>();
2661
+ let attributed = true;
2662
+ const walked = walkProjectInputs(projectRoot, filesystem);
2663
+ let complete = walked.complete;
2664
+ for (const file of walked.files) {
2665
+ try {
2666
+ const before = inputMetadataEvidence(file, filesystem);
2667
+ const key = toProjectKey(projectRoot, file, identities);
2668
+ // A file whose signature still equals the one captured around the read
2669
+ // that produced the recorded hash carries that content, so the whole
2670
+ // project does not have to be re-read to prove one delivery. A signature
2671
+ // that was already proven stays proven: its stamp has not moved since the
2672
+ // clock provably left its tick.
2673
+ if (
2674
+ before !== undefined &&
2675
+ proven !== undefined &&
2676
+ proven.signatures[key] === before.signature &&
2677
+ Object.prototype.hasOwnProperty.call(proven.hashes, key)
2678
+ ) {
2679
+ hashes[key] = proven.hashes[key]!;
2680
+ fileSignatures[key] = before.signature;
2681
+ provenSignatures[key] = before.signature;
2682
+ continue;
2683
+ }
2684
+ const contents = filesystem.readFile(file);
2685
+ const after = inputMetadataSignature(file, filesystem);
2686
+ hashes[key] = hashText(contents);
2687
+ if (
2688
+ before === undefined ||
2689
+ after === undefined ||
2690
+ before.signature !== after
2691
+ ) {
2692
+ complete = false;
2693
+ unstableFiles.add(key);
2694
+ } else {
2695
+ fileSignatures[key] = after;
2696
+ // Only a signature whose stamp's tick the filesystem's clock provably
2697
+ // left before this read may later stand in for the content comparison
2698
+ // ({@link stampSeparable}); the raw signature above still participates
2699
+ // in the generation-time stability comparison.
2700
+ if (before.separable) {
2701
+ provenSignatures[key] = after;
2702
+ }
2703
+ }
2704
+ } catch {
2705
+ // File watchers may observe a transform while another process is moving
2706
+ // or deleting files. The missing key invalidates older cache entries.
2707
+ complete = false;
2708
+ try {
2709
+ unstableFiles.add(toProjectKey(projectRoot, file, identities));
2710
+ } catch {
2711
+ // Without a key the failure cannot be attributed, so it keeps the
2712
+ // whole snapshot incomplete rather than being scoped away.
2713
+ attributed = false;
2714
+ }
2715
+ }
2716
+ }
2717
+ return {
2718
+ complete,
2719
+ directoryComplete: walked.complete && attributed,
2720
+ fileSignatures,
2721
+ hashes,
2722
+ projectDirectories: walked.directories,
2723
+ provenSignatures,
2724
+ unstableFiles,
2725
+ };
2726
+ }
2727
+
2728
+ /**
2729
+ * Enumerate every regular file under `root`, skipping well-known output and
2730
+ * tooling directories (see {@link isIgnoredProjectDirectory}).
2731
+ *
2732
+ * Uses an iterative DFS instead of `fs.readdirSync` recursion to avoid
2733
+ * unbounded call-stack depth on deep project trees. The result is sorted so
2734
+ * that hash comparisons are deterministic across OS-level directory orderings.
2735
+ */
2736
+ function walkProjectInputs(
2737
+ root: string,
2738
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
2739
+ ): {
2740
+ complete: boolean;
2741
+ directories: TtscProjectDirectorySnapshot[];
2742
+ files: string[];
2743
+ } {
2744
+ let complete = true;
2745
+ const directories: TtscProjectDirectorySnapshot[] = [];
2746
+ const files: string[] = [];
2747
+ const stack = [root];
2748
+ while (stack.length !== 0) {
2749
+ const current = stack.pop()!;
2750
+ const before = projectDirectorySignature(current, filesystem);
2751
+ if (before === undefined) {
2752
+ complete = false;
2753
+ continue;
2754
+ }
2755
+ let entries: fs.Dirent[];
2756
+ try {
2757
+ entries = filesystem.readdir(current);
2758
+ } catch {
2759
+ complete = false;
2760
+ continue;
2761
+ }
2762
+ const after = projectDirectorySignature(current, filesystem);
2763
+ if (after === undefined || before !== after) {
2764
+ complete = false;
2765
+ }
2766
+ directories.push({
2767
+ path: current,
2768
+ // If membership moved during enumeration, force the next delivery to
2769
+ // replace this generation instead of blessing a torn directory/file
2770
+ // snapshot as stable.
2771
+ signature:
2772
+ after !== undefined && before === after
2773
+ ? after
2774
+ : `unstable:${before}:${after ?? "missing"}`,
2775
+ });
2776
+ for (const entry of entries) {
2777
+ if (isIgnoredProjectDirectory(entry.name)) {
2778
+ continue;
2779
+ }
2780
+ const file = path.join(current, entry.name);
2781
+ if (entry.isDirectory()) {
2782
+ stack.push(file);
2783
+ } else if (entry.isFile()) {
2784
+ files.push(file);
2785
+ }
2786
+ }
2787
+ }
2788
+ directories.sort((left, right) => left.path.localeCompare(right.path));
2789
+ files.sort();
2790
+ return { complete, directories, files };
2791
+ }
2792
+
2793
+ /** Return a cheap identity for one directory's immediate membership. */
2794
+ function projectDirectorySignature(
2795
+ directory: string,
2796
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
2797
+ ): string | undefined {
2798
+ try {
2799
+ const stats = filesystem.statBigInt(directory);
2800
+ // Directory stamps are minted by the same clock as file stamps, so every
2801
+ // walk observation also raises the clock floor that separates them.
2802
+ observeFilesystemClock(filesystem, stats);
2803
+ if (!stats.isDirectory()) {
2804
+ return undefined;
2805
+ }
2806
+ return [
2807
+ stats.dev,
2808
+ stats.ino,
2809
+ stats.mode,
2810
+ stats.size,
2811
+ stats.mtimeNs,
2812
+ stats.ctimeNs,
2813
+ ].join(":");
2814
+ } catch {
2815
+ return undefined;
2816
+ }
2817
+ }
2818
+
2819
+ /** Compare two deterministic project-directory membership snapshots. */
2820
+ function sameProjectDirectories(
2821
+ left: readonly TtscProjectDirectorySnapshot[],
2822
+ right: readonly TtscProjectDirectorySnapshot[],
2823
+ ): boolean {
2824
+ return (
2825
+ left.length === right.length &&
2826
+ left.every(
2827
+ (directory, index) =>
2828
+ directory.path === right[index]?.path &&
2829
+ directory.signature === right[index]?.signature,
2830
+ )
2831
+ );
2832
+ }
2833
+
2834
+ /**
2835
+ * Open one directory's change notification through the cache-owned watch seam,
2836
+ * falling back to the host's own `fs.watch`. Throws exactly where the
2837
+ * underlying watch does, so callers classify a registration failure
2838
+ * themselves.
2839
+ */
2840
+ function openDirectoryWatch(
2841
+ filesystem: TtscTransformFilesystemOperations,
2842
+ directory: string,
2843
+ listener: (eventType: string, filename: string | null) => void,
2844
+ onError: () => void,
2845
+ ): { close: () => void } {
2846
+ if (filesystem.watch !== undefined) {
2847
+ return filesystem.watch(directory, listener, onError);
2848
+ }
2849
+ const watcher = fs.watch(
2850
+ directory,
2851
+ { persistent: false },
2852
+ (eventType, filename) =>
2853
+ listener(eventType, filename === null ? null : String(filename)),
2854
+ );
2855
+ watcher.on("error", onError);
2856
+ return { close: () => watcher.close() };
2857
+ }
2858
+
2859
+ /** Watch every walked directory for membership changes after generation. */
2860
+ async function createProjectMutationTracker(
2861
+ directories: readonly TtscProjectDirectorySnapshot[],
2862
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
2863
+ ): Promise<TtscProjectMutationTracker> {
2864
+ const tracker: TtscProjectMutationTracker = {
2865
+ close: () => undefined,
2866
+ failed: false,
2867
+ membershipChanged: false,
2868
+ };
2869
+ if (process.platform === "win32" && filesystem.watch === undefined) {
2870
+ await registerWindowsProjectMutationTracker(
2871
+ tracker,
2872
+ directories.map((directory) => ({ directory: directory.path })),
2873
+ false,
2874
+ filesystem,
2875
+ );
2876
+ return tracker;
2877
+ }
2878
+ const watchers: { close: () => void }[] = [];
2879
+ tracker.close = () => {
2880
+ for (const watcher of watchers) watcher.close();
2881
+ watchers.length = 0;
2882
+ };
2883
+ for (const directory of directories) {
2884
+ try {
2885
+ watchers.push(
2886
+ openDirectoryWatch(
2887
+ filesystem,
2888
+ directory.path,
2889
+ (eventType) => {
2890
+ if (eventType === "rename") tracker.membershipChanged = true;
2891
+ },
2892
+ () => {
2893
+ tracker.failed = true;
2894
+ },
2895
+ ),
2896
+ );
2897
+ } catch {
2898
+ tracker.failed = true;
2899
+ }
2900
+ }
2901
+ return tracker;
2902
+ }
2903
+
2904
+ /** Watch exact universal inputs, or their nearest existing parent if missing. */
2905
+ async function createHostInputMutationTracker(
2906
+ inputs: readonly string[],
2907
+ filesystem: TtscTransformFilesystemOperations,
2908
+ covered: ReadonlySet<string>,
2909
+ events: "all" | "rename" = "all",
2910
+ ): Promise<TtscProjectMutationTracker> {
2911
+ const identities = createHostPathIdentityContext(filesystem);
2912
+ const namesByDirectory = new Map<
2913
+ string,
2914
+ { directory: string; names: Set<string> }
2915
+ >();
2916
+ for (const input of inputs) {
2917
+ const absolute = path.resolve(input);
2918
+ const probe = filesystem.exists(absolute)
2919
+ ? { directory: path.dirname(absolute), name: path.basename(absolute) }
2920
+ : missingPathProbe(absolute, filesystem);
2921
+ const directoryIdentity = identities.resolve(probe.directory);
2922
+ let location = namesByDirectory.get(directoryIdentity.key);
2923
+ if (location === undefined) {
2924
+ location = {
2925
+ directory: directoryIdentity.path,
2926
+ names: new Set<string>(),
2927
+ };
2928
+ namesByDirectory.set(directoryIdentity.key, location);
2929
+ }
2930
+ location.names.add(
2931
+ normalizeHostInputName(
2932
+ probe.name,
2933
+ identities.caseSensitive(directoryIdentity.path),
2934
+ ),
2935
+ );
2936
+ }
2937
+ const locations = [...namesByDirectory.values()].map((location) => ({
2938
+ directory: location.directory,
2939
+ names: [...location.names],
2940
+ }));
2941
+ const tracker: TtscProjectMutationTracker = {
2942
+ close: () => undefined,
2943
+ // Coverage is the caller's claim, and it is required rather than derived
2944
+ // from the input list: an input is watched by its exact name here, but only
2945
+ // the caller knows whether the path leading to it is watched as well, which
2946
+ // is what a later validation needs before it trusts the watcher instead of
2947
+ // probing the path again. Deriving it here would hand that claim to every
2948
+ // future caller by default (samchon/ttsc#1261).
2949
+ covered,
2950
+ failed: false,
2951
+ membershipChanged: false,
2952
+ };
2953
+ if (process.platform === "win32" && filesystem.watch === undefined) {
2954
+ await registerWindowsProjectMutationTracker(
2955
+ tracker,
2956
+ locations,
2957
+ events === "all",
2958
+ filesystem,
2959
+ );
2960
+ return tracker;
2961
+ }
2962
+ const watchers: { close: () => void }[] = [];
2963
+ tracker.close = () => {
2964
+ for (const watcher of watchers) watcher.close();
2965
+ watchers.length = 0;
2966
+ };
2967
+ for (const location of locations) {
2968
+ try {
2969
+ const names = new Set(location.names);
2970
+ const caseSensitive = identities.caseSensitive(location.directory);
2971
+ watchers.push(
2972
+ openDirectoryWatch(
2973
+ filesystem,
2974
+ location.directory,
2975
+ (eventType, filename) => {
2976
+ if (events === "rename" && eventType !== "rename") {
2977
+ return;
2978
+ }
2979
+ const reported =
2980
+ filename === null
2981
+ ? null
2982
+ : normalizeHostInputName(filename, caseSensitive);
2983
+ if (reported === null || names.has(reported)) {
2984
+ tracker.membershipChanged = true;
2985
+ }
2986
+ },
2987
+ () => {
2988
+ tracker.failed = true;
2989
+ },
2990
+ ),
2991
+ );
2992
+ } catch {
2993
+ tracker.failed = true;
2994
+ }
2995
+ }
2996
+ return tracker;
2997
+ }
2998
+
2999
+ interface WindowsProjectMutationBroker {
3000
+ child: ChildProcess;
3001
+ /** Round-trips awaiting the child's reply, by request id. */
3002
+ drains: Map<number, () => void>;
3003
+ /** The acknowledgement currently in flight, shared by every waiter. */
3004
+ draining?: Promise<void>;
3005
+ nextId: number;
3006
+ pendingDrains: number;
3007
+ pendingRegistrations: number;
3008
+ trackers: Map<
3009
+ number,
3010
+ {
3011
+ ready: () => void;
3012
+ tracker: TtscProjectMutationTracker;
3013
+ }
3014
+ >;
3015
+ }
3016
+
3017
+ let windowsProjectMutationBroker: WindowsProjectMutationBroker | undefined;
3018
+
3019
+ interface WindowsMutationLocation {
3020
+ directory: string;
3021
+ names?: string[];
3022
+ }
3023
+
3024
+ /**
3025
+ * Register directory watches in an isolated Windows process.
3026
+ *
3027
+ * Node's Windows fs-event backend can assert in native code when a watched
3028
+ * temporary tree is deleted. Isolation turns that unrecoverable process abort
3029
+ * into an ordinary broker exit and a conservative cache miss in the host.
3030
+ */
3031
+ async function registerWindowsProjectMutationTracker(
3032
+ tracker: TtscProjectMutationTracker,
3033
+ locations: readonly WindowsMutationLocation[],
3034
+ allEvents: boolean,
3035
+ filesystem: TtscTransformFilesystemOperations,
3036
+ ): Promise<void> {
3037
+ const broker = getWindowsProjectMutationBroker();
3038
+ const normalized = locations.map((location) => {
3039
+ let directory: string;
3040
+ try {
3041
+ directory = filesystem.realpath(location.directory);
3042
+ } catch {
3043
+ directory = path.resolve(location.directory);
3044
+ }
3045
+ return {
3046
+ directory,
3047
+ ...(location.names === undefined ? {} : { names: location.names }),
3048
+ };
3049
+ });
3050
+ broker.pendingRegistrations += 1;
3051
+ broker.child.ref();
3052
+ broker.child.channel?.ref();
3053
+ const id = broker.nextId++;
3054
+ let resolveReady!: () => void;
3055
+ const ready = new Promise<void>((resolve) => {
3056
+ resolveReady = resolve;
3057
+ });
3058
+ broker.trackers.set(id, { ready: resolveReady, tracker });
3059
+ tracker.drain = () => drainWindowsProjectMutationBroker(broker);
3060
+ tracker.close = () => {
3061
+ const active = broker.trackers.get(id);
3062
+ if (active === undefined) return;
3063
+ broker.trackers.delete(id);
3064
+ active.ready();
3065
+ broker.child.send?.({ id, op: "remove" });
3066
+ if (broker.trackers.size === 0) {
3067
+ broker.child.disconnect?.();
3068
+ broker.child.kill();
3069
+ if (windowsProjectMutationBroker === broker) {
3070
+ windowsProjectMutationBroker = undefined;
3071
+ }
3072
+ }
3073
+ };
3074
+ broker.child.send?.({
3075
+ allEvents,
3076
+ locations: normalized,
3077
+ id,
3078
+ op: "add",
3079
+ });
3080
+ try {
3081
+ await ready;
3082
+ } finally {
3083
+ broker.pendingRegistrations -= 1;
3084
+ // `ref`/`unref` is a flag rather than a counter, so this must not clear a
3085
+ // reference an in-flight acknowledgement is holding: a delivery waiting on
3086
+ // a reply over an unreferenced channel lets the loop empty and the process
3087
+ // exit mid-build.
3088
+ if (broker.pendingRegistrations === 0 && broker.pendingDrains === 0) {
3089
+ broker.child.unref();
3090
+ broker.child.channel?.unref();
3091
+ }
3092
+ }
3093
+ }
3094
+
3095
+ function getWindowsProjectMutationBroker(): WindowsProjectMutationBroker {
3096
+ if (windowsProjectMutationBroker !== undefined) {
3097
+ return windowsProjectMutationBroker;
3098
+ }
3099
+ const child = spawn(process.execPath, ["-e", WINDOWS_WATCH_BROKER_SOURCE], {
3100
+ stdio: ["ignore", "ignore", "ignore", "ipc"],
3101
+ windowsHide: true,
3102
+ });
3103
+ const broker: WindowsProjectMutationBroker = {
3104
+ child,
3105
+ drains: new Map(),
3106
+ nextId: 1,
3107
+ pendingDrains: 0,
3108
+ pendingRegistrations: 0,
3109
+ trackers: new Map(),
3110
+ };
3111
+ const fail = (): void => {
3112
+ for (const registration of broker.trackers.values()) {
3113
+ registration.tracker.failed = true;
3114
+ registration.ready();
3115
+ }
3116
+ broker.trackers.clear();
3117
+ // A broker that died answers no round-trip. Release every waiter instead of
3118
+ // stalling the deliveries behind them; their trackers are failed now, so
3119
+ // validation falls back to proving the generation from its own state.
3120
+ for (const release of broker.drains.values()) release();
3121
+ broker.drains.clear();
3122
+ if (windowsProjectMutationBroker === broker) {
3123
+ windowsProjectMutationBroker = undefined;
3124
+ }
3125
+ };
3126
+ child.on("error", fail);
3127
+ child.on("exit", fail);
3128
+ child.on("message", (message: unknown) => {
3129
+ if (message === null || typeof message !== "object") return;
3130
+ const record = message as {
3131
+ drained?: boolean;
3132
+ failed?: boolean;
3133
+ id?: number;
3134
+ ready?: boolean;
3135
+ };
3136
+ if (typeof record.id !== "number") return;
3137
+ if (record.drained === true) {
3138
+ // Every event the child had already sent arrived before this reply, since
3139
+ // one IPC channel delivers in order.
3140
+ const release = broker.drains.get(record.id);
3141
+ broker.drains.delete(record.id);
3142
+ release?.();
3143
+ return;
3144
+ }
3145
+ const registration = broker.trackers.get(record.id);
3146
+ if (registration === undefined) return;
3147
+ if (record.failed === true) registration.tracker.failed = true;
3148
+ if (record.ready === true) registration.ready();
3149
+ if (record.ready !== true && record.failed !== true) {
3150
+ registration.tracker.membershipChanged = true;
3151
+ }
3152
+ });
3153
+ windowsProjectMutationBroker = broker;
3154
+ return broker;
3155
+ }
3156
+
3157
+ /**
3158
+ * Ask the Windows broker to acknowledge, and resolve when it does.
3159
+ *
3160
+ * The child answers after a turn of its own loop, so a watch callback it had
3161
+ * already queued has run, and the ordered IPC channel puts every message it
3162
+ * sent before the reply ahead of the reply. That is the same proof an
3163
+ * in-process watcher gets from a macrotask turn, rather than the fixed wait
3164
+ * this replaces, which guessed at the crossing (samchon/ttsc#1272).
3165
+ *
3166
+ * A broker that never answers must not hold a delivery: the wait falls back to
3167
+ * the previous fixed grace, after which validation proceeds against whatever
3168
+ * the tracker knows, exactly as it did before.
3169
+ */
3170
+ function drainWindowsProjectMutationBroker(
3171
+ broker: WindowsProjectMutationBroker,
3172
+ ): Promise<void> {
3173
+ // Every tracker of a generation lives in one broker, so one acknowledgement
3174
+ // answers for all of them. Sharing the in-flight round-trip keeps a settle to
3175
+ // a single crossing.
3176
+ broker.draining ??= startWindowsProjectMutationDrain(broker).finally(() => {
3177
+ broker.draining = undefined;
3178
+ });
3179
+ return broker.draining;
3180
+ }
3181
+
3182
+ function startWindowsProjectMutationDrain(
3183
+ broker: WindowsProjectMutationBroker,
3184
+ ): Promise<void> {
3185
+ return new Promise<void>((resolve) => {
3186
+ const id = broker.nextId++;
3187
+ let settled = false;
3188
+ const release = (): void => {
3189
+ if (settled) return;
3190
+ settled = true;
3191
+ clearTimeout(timer);
3192
+ broker.drains.delete(id);
3193
+ broker.pendingDrains -= 1;
3194
+ if (broker.pendingDrains === 0 && broker.pendingRegistrations === 0) {
3195
+ broker.child.unref();
3196
+ broker.child.channel?.unref();
3197
+ }
3198
+ resolve();
3199
+ };
3200
+ // Hold the channel open while the acknowledgement is outstanding. The
3201
+ // broker is unreferenced between requests so it never keeps a host alive,
3202
+ // and a reply is the only thing this promise can be resolved by: without
3203
+ // the reference the loop can empty while a delivery waits here, and the
3204
+ // process exits mid-build with nothing to report.
3205
+ broker.pendingDrains += 1;
3206
+ broker.child.ref();
3207
+ broker.child.channel?.ref();
3208
+ const timer = setTimeout(release, WINDOWS_MUTATION_DRAIN_FALLBACK_MS);
3209
+ broker.drains.set(id, release);
3210
+ if (broker.child.send?.({ id, op: "drain" }) !== true) {
3211
+ release();
3212
+ }
3213
+ });
3214
+ }
3215
+
3216
+ /** The wait a broker that stopped answering degrades to. */
3217
+ const WINDOWS_MUTATION_DRAIN_FALLBACK_MS = 10;
3218
+
3219
+ const WINDOWS_WATCH_BROKER_SOURCE = [
3220
+ 'const fs = require("node:fs");',
3221
+ "const groups = new Map();",
3222
+ 'process.on("message", (message) => {',
3223
+ ' if (message.op === "drain") {',
3224
+ // Two turns, not one: the first lets the loop poll for watch completions the
3225
+ // kernel had already queued, the second answers after their callbacks ran.
3226
+ " setImmediate(() => setImmediate(() => process.send?.({ drained: true, id: message.id })));",
3227
+ " return;",
3228
+ " }",
3229
+ ' if (message.op === "remove") {',
3230
+ " close(message.id);",
3231
+ " return;",
3232
+ " }",
3233
+ ' if (message.op !== "add") return;',
3234
+ " const watchers = [];",
3235
+ " let failed = false;",
3236
+ " for (const location of message.locations) {",
3237
+ " try {",
3238
+ " const names = location.names === undefined ? undefined : new Set(location.names.map((name) => name.toLowerCase()));",
3239
+ " const watcher = fs.watch(location.directory, { persistent: false }, (event, filename) => {",
3240
+ " const matches = names === undefined || filename === null || names.has(String(filename).toLowerCase());",
3241
+ ' if (matches && (message.allEvents || event === "rename")) process.send?.({ id: message.id });',
3242
+ " });",
3243
+ ' watcher.on("error", () => process.send?.({ failed: true, id: message.id }));',
3244
+ " watchers.push(watcher);",
3245
+ " } catch {",
3246
+ " failed = true;",
3247
+ " }",
3248
+ " }",
3249
+ " groups.set(message.id, watchers);",
3250
+ " process.send?.({ failed, id: message.id, ready: true });",
3251
+ "});",
3252
+ 'process.on("disconnect", () => {',
3253
+ " for (const id of groups.keys()) close(id);",
3254
+ " process.exit(0);",
3255
+ "});",
3256
+ "function close(id) {",
3257
+ " for (const watcher of groups.get(id) ?? []) watcher.close();",
3258
+ " groups.delete(id);",
3259
+ "}",
3260
+ ].join("\n");
3261
+
3262
+ /**
3263
+ * Report whether either live notification observed a membership event. This is
3264
+ * positive evidence that the generation is stale, so it outranks the question
3265
+ * of whether the notifications still work.
3266
+ */
3267
+ function reportsMembershipChange(cached: TtscCachedProjectTransform): boolean {
3268
+ return (
3269
+ cached.projectMutationTracker?.membershipChanged === true ||
3270
+ cached.hostInputMutationTracker?.membershipChanged === true ||
3271
+ cached.candidateMutationTracker?.membershipChanged === true
3272
+ );
3273
+ }
3274
+
3275
+ /**
3276
+ * Report whether the live notifications can still prove membership. A watcher
3277
+ * that failed to register, or that errored after the generation was produced,
3278
+ * proves nothing either way — it never proves the generation stale.
3279
+ */
3280
+ function notificationsProveMembership(
3281
+ cached: TtscCachedProjectTransform,
3282
+ ): boolean {
3283
+ for (const tracker of [
3284
+ cached.projectMutationTracker,
3285
+ cached.hostInputMutationTracker,
3286
+ ]) {
3287
+ if (tracker === undefined || tracker.failed) {
3288
+ return false;
3289
+ }
3290
+ }
3291
+ // The candidate tracker is optional: a generation with no absent candidate
3292
+ // opens none, and one that declined to watch them left the per-delivery probe
3293
+ // in place. Only a tracker that exists and has failed withdraws the proof.
3294
+ return cached.candidateMutationTracker?.failed !== true;
3295
+ }
3296
+
3297
+ /**
3298
+ * Yield to the loop the tracker's own watcher callbacks are queued on.
3299
+ *
3300
+ * Two turns for the same reason the broker takes two: the first gives the loop
3301
+ * a poll phase for completions the kernel had already queued, the second runs
3302
+ * after the callbacks they produced.
3303
+ */
3304
+ function drainOnNextTurn(): Promise<void> {
3305
+ return new Promise<void>((resolve) =>
3306
+ setImmediate(() => setImmediate(resolve)),
3307
+ );
3308
+ }
3309
+
3310
+ /**
3311
+ * Settle every notification the trackers' watchers have already dispatched,
3312
+ * before persistent validation reads their verdict.
3313
+ *
3314
+ * A synchronous edit returns before its watch event is applied, so without this
3315
+ * a delivery could validate against a tracker that has not been told yet. Each
3316
+ * tracker drains through its own channel, which is a macrotask turn for a
3317
+ * watcher on this loop and an ordered round-trip for one inside the Windows
3318
+ * broker. Concurrent sibling deliveries share the barrier one of them started.
3319
+ */
3320
+ async function settleProjectMutationEvents(
3321
+ cached: TtscCachedProjectTransform,
3322
+ ): Promise<void> {
3323
+ const trackers = [
3324
+ cached.projectMutationTracker,
3325
+ cached.hostInputMutationTracker,
3326
+ cached.candidateMutationTracker,
3327
+ ].filter(
3328
+ (tracker): tracker is TtscProjectMutationTracker => tracker !== undefined,
3329
+ );
3330
+ await Promise.all(
3331
+ trackers.map(async (tracker) => {
3332
+ tracker.settle ??= (tracker.drain ?? drainOnNextTurn)().finally(() => {
3333
+ tracker.settle = undefined;
3334
+ });
3335
+ await tracker.settle;
3336
+ }),
3337
+ );
3338
+ }
3339
+
3340
+ /**
1189
3341
  * Report whether an absolute `file` belongs to the project walk universe of
1190
3342
  * `root`: it lies under `root`, every component exists without traversing a
1191
3343
  * symbolic link, the leaf is a regular file, and no segment of the relative
1192
- * path is ignored. The predicate mirrors {@link listProjectInputFiles} exactly,
1193
- * so "walk-visible" here means "hashed by {@link collectProjectInputHashes}".
3344
+ * path is ignored. The predicate mirrors {@link walkProjectInputs} exactly, so
3345
+ * "walk-visible" here means "hashed by {@link collectProjectInputHashes}".
1194
3346
  * Missing paths and files reached through symlinks or Windows junctions are
1195
3347
  * out-of-walk inputs that only the reference graph can prove relevant.
1196
3348
  */
1197
3349
  export function isProjectWalkPath(
1198
3350
  root: string,
1199
3351
  file: string,
1200
- identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
3352
+ _identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
3353
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
1201
3354
  ): boolean {
1202
- if (!identities.isWithin(root, file)) {
1203
- return false;
1204
- }
1205
- const rootKey = pathIdentityKey(root, identities);
1206
- const fileKey = pathIdentityKey(file, identities);
1207
- const relative = fileKey.slice(rootKey.length).replace(/^[/\\]+/, "");
3355
+ // Walk membership is lexical. Resolving `file` to physical identity first
3356
+ // would turn `root/alias/value.ts` into `root/target/value.ts`, hide the
3357
+ // symlink segment from the lstat loop below, and falsely claim the project
3358
+ // walk hashed a path it deliberately never followed.
3359
+ const resolvedRoot = path.resolve(root);
3360
+ const relative = path.relative(resolvedRoot, path.resolve(file));
1208
3361
  if (
1209
3362
  relative.length === 0 ||
1210
3363
  relative === ".." ||
@@ -1217,12 +3370,12 @@ export function isProjectWalkPath(
1217
3370
  if (segments.some(isIgnoredProjectDirectory)) {
1218
3371
  return false;
1219
3372
  }
1220
- let current = path.resolve(root);
3373
+ let current = resolvedRoot;
1221
3374
  for (let index = 0; index < segments.length; ++index) {
1222
3375
  current = path.join(current, segments[index]!);
1223
- let stats: fs.Stats;
3376
+ let stats: fs.BigIntStats;
1224
3377
  try {
1225
- stats = fs.lstatSync(current);
3378
+ stats = filesystem.lstat(current);
1226
3379
  } catch {
1227
3380
  return false;
1228
3381
  }
@@ -1239,32 +3392,95 @@ export function isProjectWalkPath(
1239
3392
 
1240
3393
  /**
1241
3394
  * Hash a list of absolute out-of-walk input paths: content SHA-256 for a
1242
- * readable file, a stable `missing` marker otherwise. Keys use filesystem
1243
- * identity so case-only spellings share one snapshot entry, while reads retain
1244
- * the original path supplied by the compiler. The marker is state, not an error
1245
- * a recorded input disappearing (or reappearing) must change the comparison
1246
- * exactly like a content edit. Exported so `@ttsc/metro` can re-hash its
1247
- * recorded snapshot with identical semantics at cache-key time.
3395
+ * readable file, a stable directory-kind digest for a directory candidate, and
3396
+ * a stable `missing` marker otherwise. Keys use filesystem identity so
3397
+ * case-only spellings share one snapshot entry, while reads retain the original
3398
+ * path supplied by the compiler. The marker is state, not an error — a recorded
3399
+ * input disappearing (or reappearing) must change the comparison exactly like a
3400
+ * content edit. Exported so `@ttsc/metro` can re-hash its recorded snapshot
3401
+ * with identical semantics at cache-key time.
1248
3402
  */
1249
3403
  export function collectExternalInputHashes(
1250
3404
  paths: readonly string[],
3405
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
1251
3406
  ): Record<string, string> {
1252
3407
  const hashes: Record<string, string> = {};
1253
- const identities = createHostPathIdentityContext();
3408
+ const identities = createHostPathIdentityContext(filesystem);
1254
3409
  for (const file of paths) {
1255
3410
  const identity = pathIdentityKey(file, identities);
1256
3411
  if (identity in hashes) {
1257
3412
  continue;
1258
3413
  }
1259
- try {
1260
- hashes[identity] = hashText(fs.readFileSync(file));
1261
- } catch {
1262
- hashes[identity] = "missing";
1263
- }
3414
+ hashes[identity] =
3415
+ hostInputStateHash(file, filesystem) ?? MISSING_INPUT_STATE;
1264
3416
  }
1265
3417
  return hashes;
1266
3418
  }
1267
3419
 
3420
+ /**
3421
+ * Re-check a cached mixed graph/dependency input set with its owning codec,
3422
+ * reusing the recorded hash of any input whose metadata signature still holds
3423
+ * and reporting the signatures this pass captured.
3424
+ *
3425
+ * The caller adopts those signatures only once every input is proven unchanged,
3426
+ * so a signature never outlives the content comparison that justified it.
3427
+ */
3428
+ function matchesCachedExternalInputs(cached: TtscCachedProjectTransform): {
3429
+ matches: boolean;
3430
+ signatures: Record<string, string>;
3431
+ } {
3432
+ const signatures: Record<string, string> = {};
3433
+ let matches = true;
3434
+ const state = envelopeDerivation(cached);
3435
+ const graphRealpaths = cached.externalInputRealpaths ?? {};
3436
+ const filesystem = resultFilesystem(cached.result);
3437
+ const recordedHashes = cached.externalInputHashes ?? {};
3438
+ const recordedSignatures = cached.externalInputSignatures ?? {};
3439
+ // Compare each spelling against the recorded state under its own name. Two
3440
+ // spellings share one identity exactly when they selected one physical file
3441
+ // at generation time, which is the state a retarget ends, so neither may
3442
+ // answer for the other: skipping the second would leave a retargeted alias
3443
+ // unvalidated, and comparing them only through a shared key would let
3444
+ // whichever came first decide.
3445
+ for (const file of cached.externalInputPaths ??
3446
+ Object.keys(cached.externalInputHashes ?? {})) {
3447
+ const identity = derivationIdentity(state, file);
3448
+ const spelling = path.resolve(file);
3449
+ // Reuse the recorded hash of an out-of-walk input whose signature still
3450
+ // equals the one captured around the read that proved it. The signature is
3451
+ // keyed by this exact spelling, so an alias of the same physical file
3452
+ // cannot answer for it.
3453
+ const before = inputMetadataEvidence(file, filesystem);
3454
+ if (
3455
+ before !== undefined &&
3456
+ Object.prototype.hasOwnProperty.call(recordedSignatures, spelling) &&
3457
+ Object.prototype.hasOwnProperty.call(recordedHashes, identity) &&
3458
+ before.signature === recordedSignatures[spelling]
3459
+ ) {
3460
+ continue;
3461
+ }
3462
+ const hash = Object.prototype.hasOwnProperty.call(graphRealpaths, identity)
3463
+ ? graphInputStateHash(file, filesystem)
3464
+ : hostInputStateHash(file, filesystem);
3465
+ const after = inputMetadataSignature(file, filesystem);
3466
+ if (
3467
+ !Object.prototype.hasOwnProperty.call(recordedHashes, identity) ||
3468
+ recordedHashes[identity] !== (hash ?? MISSING_INPUT_STATE)
3469
+ ) {
3470
+ matches = false;
3471
+ }
3472
+ if (
3473
+ hash !== null &&
3474
+ after !== undefined &&
3475
+ before?.signature === after &&
3476
+ before.separable
3477
+ ) {
3478
+ signatures[spelling] = after;
3479
+ }
3480
+ }
3481
+ return { matches, signatures };
3482
+ }
3483
+
1268
3484
  /**
1269
3485
  * Derive the absolute out-of-walk input set of a whole project transform: the
1270
3486
  * union of every reference-graph member (edge keys and targets, globals, the
@@ -1274,16 +3490,14 @@ export function collectExternalInputHashes(
1274
3490
  * that are still missing remain in this set even under the project root: the
1275
3491
  * first walk cannot hash a file that has not been created yet.
1276
3492
  *
1277
- * A `dependenciesComplete` declaration deliberately does not narrow this set,
1278
- * unlike the per-file watch derivation. This cache replays one whole envelope,
1279
- * so its validity condition is the union over every file the envelope carries
1280
- * rather than one file's inputs; a miss here costs a re-transform, never a
1281
- * stale output; and it is the layer that re-runs the plugin's analysis, which
1282
- * is how a widened declaration is ever learned. The narrowing that matters
1283
- * lands at the bundler boundary through {@link selectWatchInputs}, which is what
1284
- * feeds persistent caches and watch graphs.
3493
+ * A `dependenciesComplete` declaration deliberately does not narrow the stored
3494
+ * set: other files in the same whole-project result can still own the omitted
3495
+ * members. Persistent validation selects the requested file's subset through
3496
+ * {@link selectWatchInputs}, while graph-free envelopes use this union as their
3497
+ * conservative fallback.
1285
3498
  */
1286
3499
  function selectExternalInputPaths(props: {
3500
+ filesystem?: TtscTransformFilesystemOperations;
1287
3501
  projectRoot: string;
1288
3502
  result: ITtscCompilerTransformation;
1289
3503
  temporaryTsconfig?: string;
@@ -1292,7 +3506,8 @@ function selectExternalInputPaths(props: {
1292
3506
  return [];
1293
3507
  }
1294
3508
  const members: string[] = [];
1295
- const identities = createHostPathIdentityContext();
3509
+ const filesystem = props.filesystem ?? DEFAULT_FILESYSTEM_OPERATIONS;
3510
+ const identities = createHostPathIdentityContext(filesystem);
1296
3511
  const resolutionCandidates = new Set<string>();
1297
3512
  const graph = props.result.graph;
1298
3513
  if (graph !== undefined) {
@@ -1326,6 +3541,19 @@ function selectExternalInputPaths(props: {
1326
3541
  members.push(...entries);
1327
3542
  }
1328
3543
  }
3544
+ if (Array.isArray(props.result.hostInputs)) {
3545
+ for (const input of props.result.hostInputs) {
3546
+ members.push(input);
3547
+ if (typeof input === "string" && input.length !== 0) {
3548
+ // Plugin discovery inputs deliberately include absent config and
3549
+ // resolution probes. A project walk cannot snapshot a path that does
3550
+ // not exist yet, even when its spelling lies below projectRoot.
3551
+ resolutionCandidates.add(
3552
+ pathIdentityKey(path.resolve(props.projectRoot, input), identities),
3553
+ );
3554
+ }
3555
+ }
3556
+ }
1329
3557
  const excluded =
1330
3558
  props.temporaryTsconfig === undefined
1331
3559
  ? undefined
@@ -1337,24 +3565,196 @@ function selectExternalInputPaths(props: {
1337
3565
  continue;
1338
3566
  }
1339
3567
  const absolute = path.resolve(props.projectRoot, member);
3568
+ const spelling = path.resolve(absolute);
1340
3569
  const identity = pathIdentityKey(absolute, identities);
1341
3570
  const missingCandidate =
1342
- resolutionCandidates.has(identity) && !fs.existsSync(absolute);
3571
+ resolutionCandidates.has(identity) && !filesystem.exists(absolute);
1343
3572
  if (
1344
3573
  identity === excluded ||
1345
- seen.has(identity) ||
3574
+ seen.has(spelling) ||
1346
3575
  (!missingCandidate &&
1347
- isProjectWalkPath(props.projectRoot, absolute, identities))
3576
+ isProjectWalkPath(props.projectRoot, absolute, identities, filesystem))
1348
3577
  ) {
1349
3578
  continue;
1350
3579
  }
1351
- seen.add(identity);
3580
+ // Preserve distinct lexical aliases even when they currently select the
3581
+ // same physical file. A later retarget must validate the alias itself.
3582
+ seen.add(spelling);
1352
3583
  output.push(absolute);
1353
3584
  }
1354
3585
  output.sort();
1355
3586
  return output;
1356
3587
  }
1357
3588
 
3589
+ /**
3590
+ * The generation's resolution candidates that do not exist, so its host-input
3591
+ * watcher can be told to announce their creation.
3592
+ *
3593
+ * A missing candidate is the one input class no proof can be memoized for: its
3594
+ * metadata cannot be read, so the signature shortcut that stands in for every
3595
+ * other input's comparison never applies, and every delivery that reaches it
3596
+ * probes the filesystem again. Watching the name instead turns that repeated
3597
+ * probe into one notification for the whole generation, using the same channel
3598
+ * and the same failure rules the universal inputs already run under
3599
+ * (samchon/ttsc#1261).
3600
+ *
3601
+ * Only absent candidates qualify. One that exists is validated by content and
3602
+ * physical identity like any other input, and adding it here would replace the
3603
+ * generation for a change that cannot affect a resolution the compiler already
3604
+ * declined to take.
3605
+ */
3606
+ function selectNotifiableAbsentInputs(props: {
3607
+ filesystem: TtscTransformFilesystemOperations;
3608
+ projectRoot: string;
3609
+ result: ITtscCompilerTransformation;
3610
+ temporaryTsconfig?: string;
3611
+ }): { candidates: string[]; watched: string[] } {
3612
+ const empty = { candidates: [], watched: [] };
3613
+ if (props.result.type === "exception") {
3614
+ return empty;
3615
+ }
3616
+ const graph = props.result.graph;
3617
+ if (graph === undefined) {
3618
+ return empty;
3619
+ }
3620
+ const identities = createHostPathIdentityContext(props.filesystem);
3621
+ const excluded =
3622
+ props.temporaryTsconfig === undefined
3623
+ ? undefined
3624
+ : pathIdentityKey(props.temporaryTsconfig, identities);
3625
+ const resolvedProjectRoot = path.resolve(props.projectRoot);
3626
+ const output: string[] = [];
3627
+ const watched: string[] = [];
3628
+ const directories = new Set<string>();
3629
+ // Two namespaces, deliberately not one set: candidates are the paths a
3630
+ // delivery may stop probing, while the chain holds the directories that carry
3631
+ // them. Sharing a set would let one silently answer for the other.
3632
+ const seen = new Set<string>();
3633
+ const chain = new Set<string>();
3634
+ for (const candidates of Object.values(graph.candidates ?? {})) {
3635
+ if (!Array.isArray(candidates)) {
3636
+ continue;
3637
+ }
3638
+ for (const candidate of candidates) {
3639
+ if (typeof candidate !== "string" || candidate.length === 0) {
3640
+ continue;
3641
+ }
3642
+ const absolute = path.resolve(props.projectRoot, candidate);
3643
+ const spelling = path.resolve(absolute);
3644
+ if (
3645
+ seen.has(spelling) ||
3646
+ (excluded !== undefined &&
3647
+ pathIdentityKey(absolute, identities) === excluded) ||
3648
+ props.filesystem.exists(absolute)
3649
+ ) {
3650
+ continue;
3651
+ }
3652
+ seen.add(spelling);
3653
+ // Collect the components of the lexical path, by the name each carries in
3654
+ // its own parent. The watcher a missing path opens follows the spelling
3655
+ // to a physical directory, so retargeting a link along the way moves the
3656
+ // answer without touching what is watched: in a pnpm layout
3657
+ // `node_modules/<pkg>` is exactly such a link, and reinstalling it makes
3658
+ // a candidate appear behind a watch still looking at the old store
3659
+ // directory. Watching `<pkg>` inside `node_modules` is what reports that.
3660
+ //
3661
+ // The collection stops at the project root, and a spelling that leaves
3662
+ // the project subtree before reaching it is not claimed at all. Above
3663
+ // that line the components are the machine's own layout rather than the
3664
+ // project's, and watching those entries costs a generation whenever an
3665
+ // unrelated process touches anything inside them; a candidate whose path
3666
+ // runs outside the subtree therefore keeps the probe it always had rather
3667
+ // than a proof this cannot complete.
3668
+ const components: string[] = [];
3669
+ let reachedProject = false;
3670
+ for (
3671
+ let child = path.dirname(spelling), parent = path.dirname(child);
3672
+ parent !== child;
3673
+ child = parent, parent = path.dirname(child)
3674
+ ) {
3675
+ if (insideProject(child, resolvedProjectRoot)) {
3676
+ components.push(child);
3677
+ continue;
3678
+ }
3679
+ // Compared through `path.relative` rather than by string, so a
3680
+ // spelling that differs from the root only in case still counts as
3681
+ // having arrived where the platform says it has.
3682
+ reachedProject = path.relative(child, resolvedProjectRoot).length === 0;
3683
+ break;
3684
+ }
3685
+ if (!reachedProject) {
3686
+ continue;
3687
+ }
3688
+ output.push(absolute);
3689
+ watched.push(absolute);
3690
+ for (const component of components) {
3691
+ if (chain.has(component)) break;
3692
+ chain.add(component);
3693
+ watched.push(component);
3694
+ directories.add(path.dirname(component));
3695
+ }
3696
+ directories.add(path.dirname(spelling));
3697
+ }
3698
+ }
3699
+ if (directories.size > NOTIFIABLE_ABSENCE_DIRECTORY_LIMIT) {
3700
+ // Past this many distinct directories the watch registration is the more
3701
+ // expensive half: a host that runs out of watch descriptors fails the
3702
+ // tracker, and a failed tracker sends every delivery to complete-snapshot
3703
+ // validation, which re-hashes the whole project. Declining to watch leaves
3704
+ // the per-delivery probe in place, which is what this replaces and is far
3705
+ // cheaper than that.
3706
+ return empty;
3707
+ }
3708
+ output.sort();
3709
+ watched.sort();
3710
+ return { candidates: output, watched };
3711
+ }
3712
+
3713
+ /**
3714
+ * Report whether a directory lies strictly below the project root.
3715
+ *
3716
+ * The boundary of what a generation may watch on a candidate's behalf: what the
3717
+ * project contains is its own layout, while the project root and everything
3718
+ * above it belongs to the machine, which nobody retargets and which changes for
3719
+ * reasons no generation should hear about.
3720
+ */
3721
+ function insideProject(directory: string, projectRoot: string): boolean {
3722
+ const relative = path.relative(
3723
+ path.resolve(projectRoot),
3724
+ path.resolve(directory),
3725
+ );
3726
+ // An empty result is the platform saying the two name the same directory,
3727
+ // which it answers for spellings that differ only in case where the path
3728
+ // module folds case. The root itself is not below itself, so the walk stops
3729
+ // there rather than one level past it.
3730
+ if (relative.length === 0) {
3731
+ return false;
3732
+ }
3733
+ // `..` alone and `../` climb out, and an absolute answer means another drive
3734
+ // or share entirely; a directory literally named `..x` does neither, which a
3735
+ // plain prefix test would misread. The project walk's own containment check
3736
+ // spells it the same way.
3737
+ return (
3738
+ relative !== ".." &&
3739
+ !relative.startsWith(`..${path.sep}`) &&
3740
+ !path.isAbsolute(relative)
3741
+ );
3742
+ }
3743
+
3744
+ /**
3745
+ * Distinct directories the absent-candidate watch may open before it declines.
3746
+ *
3747
+ * Sized well below the inotify per-user default so a project's own walk keeps
3748
+ * its share, and far above the distinct `node_modules` package directories a
3749
+ * real dependency graph produces.
3750
+ *
3751
+ * Counted lexically, over the parents of every watched name. A missing subtree
3752
+ * collapses onto the one watch its nearest existing ancestor carries, so the
3753
+ * count is an upper bound on the watches actually opened rather than their
3754
+ * number; the bound stays sound and is merely not tight.
3755
+ */
3756
+ const NOTIFIABLE_ABSENCE_DIRECTORY_LIMIT = 512;
3757
+
1358
3758
  function isIgnoredProjectDirectory(name: string): boolean {
1359
3759
  return (
1360
3760
  name === ".git" ||
@@ -1375,10 +3775,32 @@ function isIgnoredProjectDirectory(name: string): boolean {
1375
3775
  );
1376
3776
  }
1377
3777
 
3778
+ /**
3779
+ * Compare two project-walk snapshots.
3780
+ *
3781
+ * `keys` narrows the comparison to the generation's declared inputs. The walk
3782
+ * hashes every file under the project root, but only a file the compile
3783
+ * actually consumed can change an output, and a project root is a working
3784
+ * directory: a framework's generated types, a log, a coverage report, or a test
3785
+ * artifact appears and changes there while a compile runs. Comparing those
3786
+ * would declare the generation incoherent and cost a whole-project recompile
3787
+ * for every remaining module (samchon/ttsc#1246). Files entering or leaving the
3788
+ * project remain covered by the directory-membership snapshot, which is the one
3789
+ * thing a content comparison cannot see. An envelope that declares no input set
3790
+ * (a graph-free legacy host) passes `undefined` and keeps the whole-walk
3791
+ * comparison.
3792
+ */
1378
3793
  function sameHashes(
1379
3794
  left: Record<string, string>,
1380
3795
  right: Record<string, string>,
3796
+ keys?: ReadonlySet<string>,
1381
3797
  ): boolean {
3798
+ if (keys !== undefined) {
3799
+ for (const key of keys) {
3800
+ if (left[key] !== right[key]) return false;
3801
+ }
3802
+ return true;
3803
+ }
1382
3804
  const leftKeys = Object.keys(left);
1383
3805
  const rightKeys = Object.keys(right);
1384
3806
  if (leftKeys.length !== rightKeys.length) {
@@ -1387,6 +3809,150 @@ function sameHashes(
1387
3809
  return leftKeys.every((key) => right[key] === left[key]);
1388
3810
  }
1389
3811
 
3812
+ /**
3813
+ * Whether a project-walk snapshot is coherent for the inputs that matter.
3814
+ *
3815
+ * The walk reads every file under the project root, so a file nothing compiled
3816
+ * (a log being appended, a coverage report being written, a generated artifact
3817
+ * being replaced) can fail its own read sandwich while every input holds still.
3818
+ * That is not evidence about the generation, and treating it as such costs a
3819
+ * whole-project recompile per delivered module. A walk that could not enumerate
3820
+ * a directory, or a file-level failure this snapshot could not attribute to a
3821
+ * key, still taints everything: neither can be shown to leave the inputs
3822
+ * alone.
3823
+ */
3824
+ function walkSnapshotComplete(
3825
+ snapshot: {
3826
+ complete: boolean;
3827
+ directoryComplete: boolean;
3828
+ unstableFiles: Set<string>;
3829
+ },
3830
+ declared: ReadonlySet<string> | undefined,
3831
+ ): boolean {
3832
+ if (declared === undefined) {
3833
+ return snapshot.complete;
3834
+ }
3835
+ if (!snapshot.directoryComplete) {
3836
+ return false;
3837
+ }
3838
+ for (const key of snapshot.unstableFiles) {
3839
+ if (declared.has(key)) return false;
3840
+ }
3841
+ return true;
3842
+ }
3843
+
3844
+ /** {@link selectDeclaredProjectInputKeys} memoized per envelope generation. */
3845
+ function declaredProjectInputKeys(
3846
+ state: TtscEnvelopeDerivation,
3847
+ cached: TtscCachedProjectTransform,
3848
+ ): Set<string> | undefined {
3849
+ if (state.declaredInputKeysBuilt !== true) {
3850
+ state.declaredInputKeys = selectDeclaredProjectInputKeys({
3851
+ identities: state.identityContext,
3852
+ projectRoot: cached.projectRoot,
3853
+ result: cached.result,
3854
+ });
3855
+ state.declaredInputKeysBuilt = true;
3856
+ }
3857
+ return state.declaredInputKeys;
3858
+ }
3859
+
3860
+ /**
3861
+ * Project-walk keys of every input the envelope declares: the reference graph's
3862
+ * edge endpoints, globals, config chain, and resolution candidates, plus the
3863
+ * universal host inputs. Returns `undefined` for an envelope with no graph,
3864
+ * which declares no input set and therefore keeps whole-walk comparison.
3865
+ */
3866
+ function selectDeclaredProjectInputKeys(props: {
3867
+ identities: FilesystemPathIdentityContext;
3868
+ projectRoot: string;
3869
+ result: ITtscCompilerTransformation;
3870
+ }): Set<string> | undefined {
3871
+ if (props.result.type === "exception" || props.result.graph === undefined) {
3872
+ return undefined;
3873
+ }
3874
+ const graph = props.result.graph;
3875
+ const keys = new Set<string>();
3876
+ const add = (entry: unknown): void => {
3877
+ if (typeof entry !== "string" || entry.length === 0) return;
3878
+ keys.add(
3879
+ toProjectKey(
3880
+ props.projectRoot,
3881
+ path.resolve(props.projectRoot, entry),
3882
+ props.identities,
3883
+ ),
3884
+ );
3885
+ };
3886
+ for (const [source, targets] of Object.entries(graph.edges ?? {})) {
3887
+ add(source);
3888
+ if (Array.isArray(targets)) for (const target of targets) add(target);
3889
+ }
3890
+ if (Array.isArray(graph.globals))
3891
+ for (const input of graph.globals) add(input);
3892
+ if (Array.isArray(graph.configs))
3893
+ for (const input of graph.configs) add(input);
3894
+ for (const [source, candidates] of Object.entries(graph.candidates ?? {})) {
3895
+ add(source);
3896
+ if (Array.isArray(candidates)) for (const entry of candidates) add(entry);
3897
+ }
3898
+ if (Array.isArray(props.result.hostInputs))
3899
+ for (const input of props.result.hostInputs) add(input);
3900
+ // Plugin-reported dependencies are inputs the graph never sees: a utility
3901
+ // plugin's own config file is consulted by the plugin, not by the compiler.
3902
+ for (const reported of Object.values(props.result.dependencies ?? {})) {
3903
+ if (Array.isArray(reported)) for (const input of reported) add(input);
3904
+ }
3905
+ return keys;
3906
+ }
3907
+
3908
+ /**
3909
+ * Project roots already told they cannot reuse a compile, so a build reports
3910
+ * the condition once instead of once per module.
3911
+ */
3912
+ const REPORTED_UNREUSABLE_GENERATIONS = new Set<string>();
3913
+
3914
+ /**
3915
+ * Report, once per project root, that a generation cannot be reused.
3916
+ *
3917
+ * Every module of the build then recompiles the whole project, so the condition
3918
+ * is the difference between one compile and one compile per module. It stayed
3919
+ * invisible for the whole life of samchon/ttsc#970: consumers saw only a build
3920
+ * that never finished, and each investigation had to rediscover the cause from
3921
+ * outside. A named reason turns the next occurrence into a bug report instead
3922
+ * of an archaeology session.
3923
+ */
3924
+ function reportUnreusableGeneration(
3925
+ cached: TtscCachedProjectTransform,
3926
+ evidence: {
3927
+ externalInputs: boolean;
3928
+ graphProofs: boolean;
3929
+ universalInputs: boolean;
3930
+ walkStable: boolean;
3931
+ },
3932
+ ): void {
3933
+ const missing = [
3934
+ ...(evidence.walkStable ? [] : ["a stable project snapshot"]),
3935
+ ...(evidence.graphProofs ? [] : ["compiler proofs for its graph inputs"]),
3936
+ ...(evidence.externalInputs
3937
+ ? []
3938
+ : ["a complete out-of-walk input snapshot"]),
3939
+ ...(evidence.universalInputs ? [] : ["a universal host-input manifest"]),
3940
+ ];
3941
+ const key = `${cached.projectRoot}\0${missing.join(",")}`;
3942
+ if (REPORTED_UNREUSABLE_GENERATIONS.has(key)) {
3943
+ return;
3944
+ }
3945
+ REPORTED_UNREUSABLE_GENERATIONS.add(key);
3946
+ process.stderr.write(
3947
+ `ttsc: the transform cache cannot reuse this project's compile, so every ` +
3948
+ `module recompiles the whole project.\n` +
3949
+ ` project: ${cached.projectRoot}\n` +
3950
+ ` missing: ${missing.join("; ")}\n` +
3951
+ ` Please report this at https://github.com/samchon/ttsc/issues with ` +
3952
+ `this message.\n`,
3953
+ );
3954
+ }
3955
+
1390
3956
  function hashText(input: string | Buffer): string {
1391
3957
  return crypto.createHash("sha256").update(input).digest("hex");
1392
3958
  }
@@ -1396,43 +3962,170 @@ async function transformProject(props: {
1396
3962
  compilerOptions: Record<string, unknown>;
1397
3963
  currentFile: string;
1398
3964
  currentSource: string;
3965
+ filesystem: TtscTransformFilesystemOperations;
1399
3966
  plugins?: ResolvedTtscUnpluginOptions["plugins"];
3967
+ trackProjectMembership: boolean;
1400
3968
  tsconfig: string;
1401
3969
  }): Promise<TtscCachedProjectTransform> {
1402
- const configured = createTransformTsconfig(props);
1403
3970
  const projectRoot = path.dirname(props.tsconfig);
3971
+ const scratchDirectory = createTransformScratchDirectory(
3972
+ projectRoot,
3973
+ props.filesystem,
3974
+ );
3975
+ let tracker: TtscProjectMutationTracker | undefined;
3976
+ let retainTracker = false;
3977
+ let hostInputTracker: TtscProjectMutationTracker | undefined;
3978
+ let candidateTracker: TtscProjectMutationTracker | undefined;
3979
+ let retainHostInputTracker = false;
3980
+ let retainCandidateTracker = false;
1404
3981
  try {
1405
- const result = new TtscCompiler({
1406
- cwd: projectRoot,
1407
- // The generated tsconfig (if any) lives in the system temp directory,
1408
- // so declare the real project as the plugin config anchor: utility
1409
- // plugin config discovery (banner.config.*, strip.config.*,
1410
- // lint.config.*) and relative configFile resolution walk the project,
1411
- // never the temp tree. In the passthrough case this equals the
1412
- // tsconfig's own directory, the default anchor.
1413
- pluginConfigDir: projectRoot,
1414
- plugins: props.plugins,
1415
- projectRoot,
1416
- tsconfig: configured.path,
1417
- }).transform();
3982
+ const configured = createTransformTsconfig(props, scratchDirectory);
1418
3983
  const temporaryTsconfig =
1419
3984
  configured.path === props.tsconfig ? undefined : configured.path;
3985
+ const identities = createHostPathIdentityContext(props.filesystem);
3986
+ const before = collectProjectInputSnapshot(
3987
+ projectRoot,
3988
+ identities,
3989
+ props.filesystem,
3990
+ );
3991
+ tracker = props.trackProjectMembership
3992
+ ? await createProjectMutationTracker(
3993
+ before.projectDirectories,
3994
+ props.filesystem,
3995
+ )
3996
+ : undefined;
3997
+ const result = withTransformScratchEnvironment(scratchDirectory, () =>
3998
+ new TtscCompiler({
3999
+ cwd: projectRoot,
4000
+ // The generated tsconfig (if any) lives outside the project directory,
4001
+ // so declare the real project as the plugin config anchor: utility
4002
+ // plugin config discovery (banner.config.*, strip.config.*,
4003
+ // lint.config.*) and relative configFile resolution walk the project,
4004
+ // never the temp tree. In the passthrough case this equals the
4005
+ // tsconfig's own directory, the default anchor.
4006
+ pluginConfigDir: projectRoot,
4007
+ plugins: props.plugins,
4008
+ projectRoot,
4009
+ tsconfig: configured.path,
4010
+ env: transformScratchEnvironment(scratchDirectory),
4011
+ }).transform(),
4012
+ );
4013
+ TRANSFORM_RESULT_FILESYSTEM.set(result, props.filesystem);
4014
+ // Mint the generation's clock reference after the compile and before any
4015
+ // signature-recording read below, so every input written before the
4016
+ // compile sits in a provably finished tick when its signature is captured.
4017
+ mintFilesystemClockReference(scratchDirectory, props.filesystem);
4018
+ const persistentHostInputs = selectPersistentHostInputs({
4019
+ filesystem: props.filesystem,
4020
+ projectRoot,
4021
+ result,
4022
+ temporaryTsconfig,
4023
+ });
4024
+ // The generation's absent resolution candidates, which get a watcher of
4025
+ // their own below; watching one is what lets a delivery stop probing it
4026
+ // (samchon/ttsc#1261). The validation manifest stays built from the
4027
+ // universal inputs alone, so nothing else about a candidate changes.
4028
+ //
4029
+ // Derived only where a tracker could carry it: a build-scoped adapter opens
4030
+ // no watcher, so probing every candidate's existence here would be work
4031
+ // whose answer nothing can read.
4032
+ const notifiableAbsence = props.trackProjectMembership
4033
+ ? selectNotifiableAbsentInputs({
4034
+ filesystem: props.filesystem,
4035
+ projectRoot,
4036
+ result,
4037
+ temporaryTsconfig,
4038
+ })
4039
+ : { candidates: [], watched: [] };
4040
+ hostInputTracker = props.trackProjectMembership
4041
+ ? await createHostInputMutationTracker(
4042
+ persistentHostInputs,
4043
+ props.filesystem,
4044
+ // A universal input never reaches the per-input loop that consults a
4045
+ // coverage claim: an absent one is proven by its directory listing
4046
+ // instead, which re-resolves the spelling every delivery.
4047
+ new Set(),
4048
+ )
4049
+ : undefined;
4050
+ // The candidates and the directories carrying them get their own tracker,
4051
+ // listening for renames alone. Every event that can make one of these
4052
+ // paths appear is a rename — the file itself, or a component of the path
4053
+ // being created, replaced, or retargeted — so nothing is given up, while a
4054
+ // backend that reports a write below a directory as a change to that
4055
+ // directory's entry (Windows does) would otherwise replace the generation
4056
+ // every time a bundler wrote inside `node_modules`.
4057
+ candidateTracker =
4058
+ notifiableAbsence.watched.length !== 0
4059
+ ? await createHostInputMutationTracker(
4060
+ notifiableAbsence.watched,
4061
+ props.filesystem,
4062
+ new Set(notifiableAbsence.candidates),
4063
+ "rename",
4064
+ )
4065
+ : undefined;
1420
4066
  const externalInputPaths = selectExternalInputPaths({
4067
+ filesystem: props.filesystem,
1421
4068
  projectRoot,
1422
4069
  result,
1423
4070
  temporaryTsconfig,
1424
4071
  });
1425
- return {
4072
+ const inputSnapshot = collectProjectInputSnapshot(
4073
+ projectRoot,
4074
+ identities,
4075
+ props.filesystem,
4076
+ );
4077
+ // Whether the recorded snapshot describes one coherent state of the
4078
+ // project. A membership event during the compile taints it exactly like an
4079
+ // unstable walk pair; whether notifications can be *opened* is a separate
4080
+ // fact, tracked below, because a generation with no watcher is still
4081
+ // provable from its own recorded state.
4082
+ const declaredInputs = selectDeclaredProjectInputKeys({
4083
+ identities,
4084
+ projectRoot,
4085
+ result,
4086
+ });
4087
+ const walkStable =
4088
+ walkSnapshotComplete(before, declaredInputs) &&
4089
+ walkSnapshotComplete(inputSnapshot, declaredInputs) &&
4090
+ sameHashes(before.hashes, inputSnapshot.hashes, declaredInputs) &&
4091
+ sameHashes(
4092
+ before.fileSignatures,
4093
+ inputSnapshot.fileSignatures,
4094
+ declaredInputs,
4095
+ ) &&
4096
+ sameProjectDirectories(
4097
+ before.projectDirectories,
4098
+ inputSnapshot.projectDirectories,
4099
+ ) &&
4100
+ tracker?.membershipChanged !== true &&
4101
+ hostInputTracker?.membershipChanged !== true &&
4102
+ candidateTracker?.membershipChanged !== true;
4103
+ const notificationsAvailable =
4104
+ tracker?.failed !== true &&
4105
+ hostInputTracker?.failed !== true &&
4106
+ candidateTracker?.failed !== true;
4107
+ // Overlay the in-memory source only after proving the two on-disk snapshots
4108
+ // stable; an unsaved editor buffer must not look like a compile-time race.
4109
+ const currentFileKey = toProjectKey(
4110
+ projectRoot,
4111
+ props.currentFile,
4112
+ identities,
4113
+ );
4114
+ inputSnapshot.hashes[currentFileKey] = hashText(props.currentSource);
4115
+ // That overlay makes this one key the only recorded hash a disk signature
4116
+ // cannot stand for: the bytes it names came from the bundler, not the file.
4117
+ delete inputSnapshot.provenSignatures[currentFileKey];
4118
+ const cached: TtscCachedProjectTransform = {
1426
4119
  // Capture the out-of-walk input hashes while the generation is fresh so
1427
4120
  // cache validation can re-check them; computed before dispose so the
1428
4121
  // exclusion of the temp-dir tsconfig is the only reason it never keys.
1429
- externalInputHashes: collectExternalInputHashes(externalInputPaths),
4122
+ externalInputHashes: {},
4123
+ externalInputRealpaths: {},
1430
4124
  externalInputPaths,
1431
- inputHashes: collectInputHashes({
1432
- currentFile: props.currentFile,
1433
- currentSource: props.currentSource,
1434
- projectRoot,
1435
- }),
4125
+ inputHashes: inputSnapshot.hashes,
4126
+ inputSignatures: inputSnapshot.provenSignatures,
4127
+ projectDirectories: inputSnapshot.projectDirectories,
4128
+ projectSnapshotComplete: false,
1436
4129
  projectRoot,
1437
4130
  result,
1438
4131
  servedFiles: new Set(),
@@ -1441,16 +4134,106 @@ async function transformProject(props: {
1441
4134
  // but deleted file would invalidate every persistent-cache snapshot.
1442
4135
  ...(temporaryTsconfig === undefined ? {} : { temporaryTsconfig }),
1443
4136
  };
4137
+ const externalInputSnapshot = captureExternalInputSnapshot(
4138
+ cached,
4139
+ externalInputPaths,
4140
+ );
4141
+ cached.externalInputHashes = externalInputSnapshot.hashes;
4142
+ cached.externalInputRealpaths = externalInputSnapshot.realpaths;
4143
+ cached.externalInputSignatures = externalInputSnapshot.signatures;
4144
+ // Evaluate every half, rather than short-circuiting, so a generation that
4145
+ // cannot be reused can say which evidence it lacked. The extra work runs
4146
+ // only on the failing path, where the alternative is recompiling the whole
4147
+ // project for every remaining module.
4148
+ const graphProofs = matchesCompilerGraphInputProofs(cached);
4149
+ const universalInputs =
4150
+ captureUniversalHostInputValidation(cached, props.currentFile) !==
4151
+ undefined;
4152
+ const stableProjectSnapshot =
4153
+ walkStable &&
4154
+ graphProofs &&
4155
+ externalInputSnapshot.complete &&
4156
+ universalInputs;
4157
+ // Only a caching host loses anything here: without a cache every delivery
4158
+ // compiles by design, so an unprovable generation costs it nothing.
4159
+ if (!stableProjectSnapshot && props.trackProjectMembership) {
4160
+ reportUnreusableGeneration(cached, {
4161
+ externalInputs: externalInputSnapshot.complete,
4162
+ graphProofs,
4163
+ universalInputs,
4164
+ walkStable,
4165
+ });
4166
+ }
4167
+ cached.projectSnapshotComplete = stableProjectSnapshot;
4168
+ // Attach notifications only while they can actually prove membership. A
4169
+ // generation that could not open its watchers keeps its recorded snapshot
4170
+ // and validates through it, rather than losing the cache entirely.
4171
+ const notifying = stableProjectSnapshot && notificationsAvailable;
4172
+ if (notifying && tracker !== undefined) {
4173
+ cached.projectMutationTracker = tracker;
4174
+ }
4175
+ if (notifying && hostInputTracker !== undefined) {
4176
+ cached.hostInputMutationTracker = hostInputTracker;
4177
+ }
4178
+ if (notifying && candidateTracker !== undefined) {
4179
+ cached.candidateMutationTracker = candidateTracker;
4180
+ }
4181
+ // Every tracker the generation published is retained, and every tracker it
4182
+ // did not is closed below. Naming only two of the three would close a
4183
+ // published candidate tracker the moment either of the others was absent,
4184
+ // and that is the one tracker whose silence is read as evidence.
4185
+ retainTracker = notifying && tracker !== undefined;
4186
+ retainHostInputTracker = notifying && hostInputTracker !== undefined;
4187
+ retainCandidateTracker = notifying && candidateTracker !== undefined;
4188
+ return cached;
1444
4189
  } finally {
1445
- configured.dispose();
4190
+ try {
4191
+ if (!retainTracker && tracker !== undefined) {
4192
+ tracker.close();
4193
+ }
4194
+ } finally {
4195
+ try {
4196
+ if (!retainHostInputTracker && hostInputTracker !== undefined) {
4197
+ hostInputTracker.close();
4198
+ }
4199
+ } finally {
4200
+ try {
4201
+ if (!retainCandidateTracker && candidateTracker !== undefined) {
4202
+ candidateTracker.close();
4203
+ }
4204
+ } finally {
4205
+ fs.rmSync(scratchDirectory, { force: true, recursive: true });
4206
+ }
4207
+ }
4208
+ }
1446
4209
  }
1447
4210
  }
1448
4211
 
1449
- function createTransformTsconfig(props: {
1450
- aliasPaths: Record<string, string[]>;
1451
- compilerOptions: Record<string, unknown>;
1452
- tsconfig: string;
1453
- }): { path: string; dispose: () => void } {
4212
+ /** Exclude the disposed overlay tsconfig from live host-input tracking. */
4213
+ function selectPersistentHostInputs(props: {
4214
+ filesystem: TtscTransformFilesystemOperations;
4215
+ projectRoot: string;
4216
+ result: ITtscCompilerTransformation;
4217
+ temporaryTsconfig?: string;
4218
+ }): string[] {
4219
+ if (props.result.type === "exception") return [];
4220
+ const inputs = selectListedFiles(props.projectRoot, props.result.hostInputs);
4221
+ if (props.temporaryTsconfig === undefined) return inputs;
4222
+ const identities = createHostPathIdentityContext(props.filesystem);
4223
+ const temporary = pathIdentityKey(props.temporaryTsconfig, identities);
4224
+ return inputs.filter(
4225
+ (input) => pathIdentityKey(input, identities) !== temporary,
4226
+ );
4227
+ }
4228
+
4229
+ function createTransformTsconfig(
4230
+ props: {
4231
+ aliasPaths: Record<string, string[]>;
4232
+ compilerOptions: Record<string, unknown>;
4233
+ tsconfig: string;
4234
+ },
4235
+ scratchDirectory: string,
4236
+ ): { path: string } {
1454
4237
  const compilerOptions = normalizeCompilerOptionsForGeneratedTsconfig(
1455
4238
  {
1456
4239
  ...props.compilerOptions,
@@ -1459,14 +4242,10 @@ function createTransformTsconfig(props: {
1459
4242
  path.dirname(props.tsconfig),
1460
4243
  );
1461
4244
  if (Object.keys(compilerOptions).length === 0) {
1462
- return {
1463
- path: props.tsconfig,
1464
- dispose: () => undefined,
1465
- };
4245
+ return { path: props.tsconfig };
1466
4246
  }
1467
4247
 
1468
- const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ttsc-unplugin-"));
1469
- const file = path.join(directory, "tsconfig.json");
4248
+ const file = path.join(scratchDirectory, "tsconfig.json");
1470
4249
  fs.writeFileSync(
1471
4250
  file,
1472
4251
  JSON.stringify(
@@ -1479,19 +4258,134 @@ function createTransformTsconfig(props: {
1479
4258
  ),
1480
4259
  "utf8",
1481
4260
  );
4261
+ return { path: file };
4262
+ }
4263
+
4264
+ /** Create compiler scratch storage outside the project snapshot and watchers. */
4265
+ function createTransformScratchDirectory(
4266
+ projectRoot: string,
4267
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
4268
+ ): string {
4269
+ const root = path.resolve(projectRoot);
4270
+ const canonicalRoot = filesystem.realpath(root);
4271
+ const platformTemp =
4272
+ process.platform === "win32" && process.env.LOCALAPPDATA
4273
+ ? path.join(process.env.LOCALAPPDATA, "Temp")
4274
+ : "/tmp";
4275
+ const candidates = [
4276
+ os.tmpdir(),
4277
+ platformTemp,
4278
+ path.dirname(root),
4279
+ os.homedir(),
4280
+ ];
4281
+ const canonicalCandidates = new Set<string>();
4282
+ let failure: unknown;
4283
+ for (const candidate of new Set(candidates.map((dir) => path.resolve(dir)))) {
4284
+ if (pathIsWithin(candidate, root)) continue;
4285
+ let canonicalCandidate: string;
4286
+ try {
4287
+ canonicalCandidate = filesystem.realpath(candidate);
4288
+ } catch (error) {
4289
+ failure = error;
4290
+ continue;
4291
+ }
4292
+ if (
4293
+ pathIsWithin(canonicalCandidate, canonicalRoot) ||
4294
+ canonicalCandidates.has(canonicalCandidate)
4295
+ ) {
4296
+ continue;
4297
+ }
4298
+ canonicalCandidates.add(canonicalCandidate);
4299
+ let directory: string;
4300
+ try {
4301
+ directory = fs.mkdtempSync(
4302
+ path.join(canonicalCandidate, "ttsc-unplugin-"),
4303
+ );
4304
+ } catch (error) {
4305
+ failure = error;
4306
+ continue;
4307
+ }
4308
+ let canonicalDirectory: string;
4309
+ try {
4310
+ canonicalDirectory = filesystem.realpath(directory);
4311
+ } catch (error) {
4312
+ try {
4313
+ fs.rmdirSync(directory);
4314
+ } catch (cleanupError) {
4315
+ throw cleanupError;
4316
+ }
4317
+ failure = error;
4318
+ continue;
4319
+ }
4320
+ // Use the postflight canonical spelling from this point onward. Returning
4321
+ // the candidate-relative spelling would let another process retarget its
4322
+ // parent symlink/junction after validation, redirecting compiler writes or
4323
+ // the final recursive removal into the project.
4324
+ if (!pathIsWithin(canonicalDirectory, canonicalRoot)) {
4325
+ return canonicalDirectory;
4326
+ }
4327
+ // Refuse the result and synchronously remove only our empty random child
4328
+ // through the identity that the postflight check just classified.
4329
+ fs.rmdirSync(canonicalDirectory);
4330
+ }
4331
+ throw (
4332
+ failure ??
4333
+ new Error("ttsc: no temporary directory exists outside the project")
4334
+ );
4335
+ }
4336
+
4337
+ function pathIsWithin(child: string, parent: string): boolean {
4338
+ const relative = path.relative(parent, child);
4339
+ return (
4340
+ relative === "" ||
4341
+ (relative !== ".." &&
4342
+ !relative.startsWith(`..${path.sep}`) &&
4343
+ !path.isAbsolute(relative))
4344
+ );
4345
+ }
4346
+
4347
+ /** Route all compiler/plugin scratch to one owned directory outside project. */
4348
+ function transformScratchEnvironment(directory: string): NodeJS.ProcessEnv {
1482
4349
  return {
1483
- path: file,
1484
- dispose: () => fs.rmSync(directory, { force: true, recursive: true }),
4350
+ ...process.env,
4351
+ TEMP: directory,
4352
+ TMP: directory,
4353
+ TMPDIR: directory,
4354
+ };
4355
+ }
4356
+
4357
+ /** Scope parent-process temp consumers to the same owned scratch directory. */
4358
+ function withTransformScratchEnvironment<T>(
4359
+ scratchDirectory: string,
4360
+ callback: () => T,
4361
+ ): T {
4362
+ const environment = transformScratchEnvironment(scratchDirectory);
4363
+ const previous = {
4364
+ TEMP: process.env.TEMP,
4365
+ TMP: process.env.TMP,
4366
+ TMPDIR: process.env.TMPDIR,
1485
4367
  };
4368
+ process.env.TEMP = environment.TEMP;
4369
+ process.env.TMP = environment.TMP;
4370
+ process.env.TMPDIR = environment.TMPDIR;
4371
+ try {
4372
+ return callback();
4373
+ } finally {
4374
+ for (const [name, value] of Object.entries(previous)) {
4375
+ if (value === undefined) delete process.env[name];
4376
+ else process.env[name] = value;
4377
+ }
4378
+ }
1486
4379
  }
1487
4380
 
1488
4381
  /**
1489
4382
  * Resolve all relative paths inside `compilerOptions` against `tsconfigDir`.
1490
4383
  *
1491
- * The generated tsconfig lives in a system temp directory, so any relative path
1492
- * (e.g. `"outDir": "../dist"`) that was meaningful relative to the original
1493
- * tsconfig must be converted to an absolute path before writing the generated
1494
- * file. Otherwise TypeScript-Go resolves it against the temp dir.
4384
+ * The generated tsconfig lives in a temporary directory outside the project, so
4385
+ * any relative path (e.g. `"outDir": "../dist"`) that was meaningful relative
4386
+ * to the original tsconfig must be converted to an absolute path before writing
4387
+ * the generated file. Otherwise TypeScript-Go resolves it against the temp
4388
+ * dir.
1495
4389
  *
1496
4390
  * `paths` targets are absolutized for the same reason, with the extra twist
1497
4391
  * that TypeScript-Go rejects bare non-relative targets outright (TS5090) and
@@ -1826,7 +4720,11 @@ function formatUnknownError(error: unknown): string {
1826
4720
  * compiler will error if that file does not exist, which is the correct
1827
4721
  * behavior for a mis-configured project.
1828
4722
  */
1829
- function resolveTsconfig(file: string, tsconfig?: string): string {
4723
+ function resolveTsconfig(
4724
+ file: string,
4725
+ tsconfig?: string,
4726
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
4727
+ ): string {
1830
4728
  if (tsconfig !== undefined) {
1831
4729
  return path.isAbsolute(tsconfig)
1832
4730
  ? tsconfig
@@ -1836,7 +4734,7 @@ function resolveTsconfig(file: string, tsconfig?: string): string {
1836
4734
  let current = path.dirname(file);
1837
4735
  while (true) {
1838
4736
  const candidate = path.join(current, "tsconfig.json");
1839
- if (fs.existsSync(candidate)) {
4737
+ if (filesystem.exists(candidate)) {
1840
4738
  return candidate;
1841
4739
  }
1842
4740
  const parent = path.dirname(current);