@ttsc/unplugin 0.28.2 → 0.28.4

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.
Files changed (46) hide show
  1. package/README.md +71 -7
  2. package/lib/api.js +3 -0
  3. package/lib/api.js.map +1 -1
  4. package/lib/api.mjs +1 -0
  5. package/lib/api.mjs.map +1 -1
  6. package/lib/core/index.d.cts +17 -6
  7. package/lib/core/index.d.mts +17 -6
  8. package/lib/core/index.d.ts +17 -6
  9. package/lib/core/index.js +131 -18
  10. package/lib/core/index.js.map +1 -1
  11. package/lib/core/index.mjs +130 -19
  12. package/lib/core/index.mjs.map +1 -1
  13. package/lib/core/transform.d.cts +116 -29
  14. package/lib/core/transform.d.mts +116 -29
  15. package/lib/core/transform.d.ts +116 -29
  16. package/lib/core/transform.js +1502 -222
  17. package/lib/core/transform.js.map +1 -1
  18. package/lib/core/transform.mjs +1503 -223
  19. package/lib/core/transform.mjs.map +1 -1
  20. package/lib/core/tsconfigPaths.d.cts +61 -0
  21. package/lib/core/tsconfigPaths.d.mts +61 -0
  22. package/lib/core/tsconfigPaths.d.ts +61 -0
  23. package/lib/core/tsconfigPaths.js +190 -7
  24. package/lib/core/tsconfigPaths.js.map +1 -1
  25. package/lib/core/tsconfigPaths.mjs +188 -8
  26. package/lib/core/tsconfigPaths.mjs.map +1 -1
  27. package/lib/next.d.cts +27 -10
  28. package/lib/next.d.mts +27 -10
  29. package/lib/next.d.ts +27 -10
  30. package/lib/next.js +229 -8
  31. package/lib/next.js.map +1 -1
  32. package/lib/next.mjs +229 -8
  33. package/lib/next.mjs.map +1 -1
  34. package/lib/turbopack.d.cts +5 -4
  35. package/lib/turbopack.d.mts +5 -4
  36. package/lib/turbopack.d.ts +5 -4
  37. package/lib/turbopack.js +13 -7
  38. package/lib/turbopack.js.map +1 -1
  39. package/lib/turbopack.mjs +14 -8
  40. package/lib/turbopack.mjs.map +1 -1
  41. package/package.json +3 -3
  42. package/src/core/index.ts +136 -18
  43. package/src/core/transform.ts +2070 -254
  44. package/src/core/tsconfigPaths.ts +254 -8
  45. package/src/next.ts +262 -10
  46. package/src/turbopack.ts +13 -9
@@ -17,8 +17,12 @@ import type { TransformResult } from "unplugin";
17
17
 
18
18
  import type { ResolvedTtscUnpluginOptions } from "./options";
19
19
  import {
20
+ type ITtscProjectMembershipPolicy,
21
+ PERMISSIVE_PROJECT_MEMBERSHIP_POLICY,
20
22
  absolutizePathsTarget,
23
+ mergeMembershipPolicyOverlay,
21
24
  readEffectiveTsconfigPaths,
25
+ readProjectMembershipPolicy,
22
26
  } from "./tsconfigPaths";
23
27
 
24
28
  /**
@@ -34,27 +38,72 @@ export type TtscTransformResult = Exclude<
34
38
  >;
35
39
 
36
40
  /**
37
- * Normalised alias entry used when building the `paths` overlay for the
38
- * generated tsconfig. Derived from either a Vite array alias or a webpack/
39
- * Rspack object alias.
41
+ * One alias entry as the host declared it, before anything decides whether a
42
+ * tsconfig `paths` map can express it.
43
+ *
44
+ * Both of Vite's spellings reach here, the `{ "@": "/src" }` object and the `{
45
+ * find, replacement }` array, and only Vite's: `aliases` is populated in
46
+ * `vite.configResolved` alone, and every other adapter passes `undefined`. (The
47
+ * previous wording credited the object form to webpack and Rspack, which never
48
+ * supply one.)
49
+ *
50
+ * `find` is `unknown` rather than `string` because Vite's array form accepts a
51
+ * `RegExp`, and narrowing it here is what used to drop that form before the one
52
+ * place that could report the drop ever saw it (samchon/ttsc#1315).
40
53
  */
41
- export interface TtscTransformAlias {
42
- /** The alias key (module specifier prefix). */
43
- find: string;
54
+ interface TtscDeclaredAlias {
55
+ /** The alias key, as declared: a module specifier prefix, or a `RegExp`. */
56
+ find: unknown;
44
57
  /** Absolute or cwd-relative path that the alias points to. */
45
58
  replacement: string;
46
59
  }
47
60
 
48
- /** One directory's cheap project-membership identity at generation time. */
61
+ /** One directory's project-membership identity at generation time. */
49
62
  interface TtscProjectDirectorySnapshot {
50
63
  /** Absolute directory spelling used by the project walk. */
51
64
  path: string;
52
- /** Metadata signature that changes when its immediate membership changes. */
65
+ /**
66
+ * Whether this directory's subtree can hold a program input.
67
+ *
68
+ * A directory that cannot is still walked and still watched, so a source
69
+ * appearing in it later is noticed, but it takes no part in the membership
70
+ * comparison. That is what lets a bundler create its output directory and
71
+ * fill it without voiding a generation no compiler input touched, for any
72
+ * output directory rather than for fifteen names (samchon/ttsc#1307).
73
+ */
74
+ relevant: boolean;
75
+ /**
76
+ * Digest of the entries the walk itself considers: every immediate child the
77
+ * ignore list does not drop, with its kind.
78
+ *
79
+ * Deliberately not the directory's own metadata. A directory's stamp moves
80
+ * whenever _any_ entry is added or removed, including the ones the walk
81
+ * exists to ignore, so a bundler emitting into `dist/` — or merely creating
82
+ * that directory for the first time — moved the project root's stamp and
83
+ * voided a generation that no compiler input had touched. The ignore list
84
+ * only protects the generation if the membership proof honours it too.
85
+ */
53
86
  signature: string;
54
87
  }
55
88
 
89
+ /** One project-walk observation that could not prove a coherent snapshot. */
90
+ interface TtscProjectWalkFailure {
91
+ kind:
92
+ | "directory-changed-during-walk"
93
+ | "directory-metadata-unavailable"
94
+ | "directory-read-failed"
95
+ | "file-changed-during-read"
96
+ | "file-read-failed";
97
+ /** Absolute lexical spelling observed by the walk. */
98
+ path: string;
99
+ }
100
+
56
101
  /** Generation-scoped directory watchers used to detect membership changes. */
57
102
  interface TtscProjectMutationTracker {
103
+ /** Absolute paths named by generation-time mutation events. */
104
+ changes: Set<string>;
105
+ /** Whether additional event paths were discarded after the witness bound. */
106
+ changesOmitted: boolean;
58
107
  close: () => void;
59
108
  /**
60
109
  * Absolute spellings whose creation, change or removal this tracker would
@@ -82,6 +131,174 @@ interface TtscProjectMutationTracker {
82
131
  settle?: Promise<void>;
83
132
  }
84
133
 
134
+ /** One reason a whole-project transform cannot become a reusable generation. */
135
+ interface TtscGenerationProofFailure {
136
+ domain: "external" | "graph" | "host" | "project";
137
+ /** Machine-readable failure class printed verbatim in terminal diagnostics. */
138
+ kind: string;
139
+ /** Optional producer detail, such as the native compiler observation failure. */
140
+ detail?: string;
141
+ /** Absolute lexical spelling of the input or directory that failed proof. */
142
+ path?: string;
143
+ }
144
+
145
+ /** Bounded proof witnesses for one transform attempt. */
146
+ interface TtscGenerationProofFailures {
147
+ entries: TtscGenerationProofFailure[];
148
+ omitted: number;
149
+ seen: Set<string>;
150
+ }
151
+
152
+ /** Filesystem state that may authorize replacing one terminal failed generation. */
153
+ interface TtscFailedGenerationValidation {
154
+ /** Last attempted generation, retained only as a comparison baseline. */
155
+ cached: TtscCachedProjectTransform;
156
+ /** Input keys whose content can affect the generation, or the whole walk. */
157
+ declaredInputs: ReadonlySet<string> | undefined;
158
+ /** Fingerprints of every out-of-walk and exact host input. */
159
+ inputStates: ReadonlyMap<string, string>;
160
+ /** Unmodified on-disk project hashes before the in-memory source overlay. */
161
+ projectInputHashes: Readonly<Record<string, string>>;
162
+ /** Coherence and exact failure state of the final project walk. */
163
+ projectWalkComplete: boolean;
164
+ projectWalkFailures: string;
165
+ }
166
+
167
+ /**
168
+ * A verdict about one generation that later deliveries replay instead of
169
+ * repeating the whole compile behind it.
170
+ *
171
+ * The two kinds are replayed on different evidence, and each carries its own: a
172
+ * pass verdict knows the pass it belongs to, and an unstable generation knows
173
+ * the recorded environment it was proven against.
174
+ */
175
+ abstract class TtscTerminalGenerationError extends Error {}
176
+
177
+ /**
178
+ * The compile succeeded and produced no output for one requested module,
179
+ * because the program does not contain it.
180
+ *
181
+ * Not a terminal generation error, and deliberately not a build failure. It is
182
+ * a fact about one file, and the answer to it is to leave that file to the host
183
+ * (samchon/ttsc#1308). It is a distinct type rather than a message match so the
184
+ * decision travels as a type: `@ttsc/metro` used to recognise this case by
185
+ * searching the message text for "did not return output", which is how one
186
+ * product came to hold two different answers to one condition.
187
+ */
188
+ class TtscMissingProgramOutputError extends Error {
189
+ /** The module the bundler asked for. */
190
+ public readonly file: string;
191
+ /** The project config whose program does not contain it. */
192
+ public readonly tsconfig: string;
193
+ public constructor(file: string, tsconfig: string) {
194
+ super(
195
+ `ttsc: ${file} is not part of the program described by ${tsconfig}, so it was left untransformed. Add it to that project's "include" if ttsc plugins should apply to it.`,
196
+ );
197
+ this.name = "TtscMissingProgramOutputError";
198
+ this.file = file;
199
+ this.tsconfig = tsconfig;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * A bounded proof failure that stays authoritative until its inputs change.
205
+ *
206
+ * This is the adapter failing to _obtain_ a coherent snapshot — a race it lost
207
+ * — so a later attempt may well succeed with the same inputs. It is retried
208
+ * when its recorded environment moves, and a new delivery epoch grants it the
209
+ * one fresh attempt the per-pass cache clear used to give it
210
+ * (samchon/ttsc#1300).
211
+ */
212
+ class TtscUnstableGenerationError extends TtscTerminalGenerationError {
213
+ public readonly validation: TtscFailedGenerationValidation;
214
+
215
+ public constructor(
216
+ message: string,
217
+ validation: TtscFailedGenerationValidation,
218
+ ) {
219
+ super(message);
220
+ this.name = "TtscUnstableGenerationError";
221
+ this.validation = validation;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * A compile this pass already attempted, whose envelope failed outright.
227
+ *
228
+ * The envelope cannot say whether the host reported diagnostics about the
229
+ * project or failed to run at all: an ordinary type error arrives as an
230
+ * `"exception"` carrying the compiler's own diagnostic text, exactly as a
231
+ * crashed host would. Sniffing that message to tell the two apart would be a
232
+ * guess, so the adapter uses the one boundary it genuinely owns. Inside a pass
233
+ * the answer is already settled, so every later module replays it instead of
234
+ * repeating a whole-project transform to reach the same verdict, which is what
235
+ * made a single broken save cost one compile per delivered module
236
+ * (samchon/ttsc#1303).
237
+ *
238
+ * The scope is exactly the pass. A host whose `buildStart` repeats drops the
239
+ * verdict at its next rebuild, so a transient host failure costs that one
240
+ * rebuild. A host with no pass boundary never retains one at all and keeps
241
+ * retrying on its very next delivery. Between them sits a host that opens
242
+ * exactly one pass for its whole process — Bun's runtime plugin, and a Vite dev
243
+ * server configured with `server.watch: null` — where the verdict lasts the
244
+ * session. That follows from what those hosts already publish about themselves,
245
+ * that their session is one immutable load session and the remedy for changed
246
+ * inputs is to restart, and it is the deliberate trade: without it, one type
247
+ * error costs such a session a whole-project compile per delivered module,
248
+ * which is the workload samchon/ttsc#970 is about.
249
+ *
250
+ * It carries the original error's message, stack and `cause` rather than
251
+ * replacing them, so what a bundler reports is what it reported before the
252
+ * verdict existed.
253
+ */
254
+ class TtscPassVerdictError extends TtscTerminalGenerationError {
255
+ /** The delivery pass this verdict belongs to, and its whole scope. */
256
+ public readonly epoch: number;
257
+
258
+ public constructor(original: unknown, epoch: number) {
259
+ super(
260
+ original instanceof Error
261
+ ? original.message
262
+ : formatUnknownError(original),
263
+ { cause: original },
264
+ );
265
+ if (original instanceof Error) {
266
+ this.name = original.name;
267
+ if (original.stack !== undefined) this.stack = original.stack;
268
+ } else {
269
+ this.name = "TtscPassVerdictError";
270
+ }
271
+ this.epoch = epoch;
272
+ }
273
+ }
274
+
275
+ /** Proof witnesses retained beside a compiler result without extending its API. */
276
+ const TRANSFORM_GENERATION_FAILURES = new WeakMap<
277
+ ITtscCompilerTransformation,
278
+ TtscGenerationProofFailures
279
+ >();
280
+
281
+ /** Retry baselines retained only for attempts that could not be published. */
282
+ const TRANSFORM_FAILED_GENERATION_VALIDATIONS = new WeakMap<
283
+ ITtscCompilerTransformation,
284
+ TtscFailedGenerationValidation
285
+ >();
286
+
287
+ /** Cache promises whose unchanged terminal verdict may be replayed. */
288
+ const TERMINAL_TRANSFORM_GENERATIONS = new WeakMap<
289
+ Promise<TtscCachedProjectTransform>,
290
+ TtscTerminalGenerationError
291
+ >();
292
+
293
+ /** Maximum witnesses printed and retained for each failed transform attempt. */
294
+ const MAX_GENERATION_PROOF_FAILURES = 8;
295
+
296
+ /** Maximum exact mutation paths kept after a tracker already proved a change. */
297
+ const MAX_GENERATION_MUTATION_PATHS = 8;
298
+
299
+ /** One retry absorbs a transient watch write without admitting an infinite loop. */
300
+ const TRANSFORM_GENERATION_ATTEMPTS = 2;
301
+
85
302
  /**
86
303
  * A single entry in the project transform cache.
87
304
  *
@@ -138,6 +355,28 @@ export interface TtscCachedProjectTransform {
138
355
  * transform.
139
356
  */
140
357
  inputHashes: Record<string, string>;
358
+ /**
359
+ * What the resolved configuration admitted into this generation's program.
360
+ *
361
+ * Recorded per generation rather than read per validation because it is a
362
+ * property of the configuration the compile ran under, so a later delivery
363
+ * must judge membership by the same rule the compile did. A tsconfig edit
364
+ * that changes the rule also changes a declared input, which replaces the
365
+ * generation and its policy together.
366
+ */
367
+ membershipPolicy: ITtscProjectMembershipPolicy;
368
+ /**
369
+ * Files already reported as absent from the program, and the pass that
370
+ * reporting belongs to, so the notice is one per file per pass rather than
371
+ * one per delivery.
372
+ */
373
+ missingOutputReported?: Set<string>;
374
+ missingOutputEpoch?: number;
375
+ /**
376
+ * The project config this generation compiled, so a module the program does
377
+ * not contain can be told which program that was.
378
+ */
379
+ tsconfig: string;
141
380
  /**
142
381
  * Metadata signature of each {@link inputHashes} entry whose hash was proven
143
382
  * against an unracing read of the file on disk, in a tick the observed
@@ -149,6 +388,13 @@ export interface TtscCachedProjectTransform {
149
388
  * disk bytes against the recorded hash, and may record a signature then.
150
389
  */
151
390
  inputSignatures?: Record<string, string>;
391
+ /**
392
+ * Raw source hash of every readable key in the transform output, keyed by
393
+ * filesystem identity. Unlike {@link inputHashes}, this includes source
394
+ * outputs outside the project walk without adding arbitrary output keys to
395
+ * the complete project snapshot.
396
+ */
397
+ sourceHashes?: Record<string, string>;
152
398
  /** Metadata snapshot of every directory in the stable generation walk. */
153
399
  projectDirectories?: TtscProjectDirectorySnapshot[];
154
400
  /** Live notification state for universal host-input changes. */
@@ -193,19 +439,50 @@ export interface TtscCachedProjectTransform {
193
439
  projectRoot: string;
194
440
  /** Raw compiler output returned by {@link TtscCompiler.transform}. */
195
441
  result: ITtscCompilerTransformation;
442
+ /**
443
+ * The delivery epoch this generation is currently settled against, or
444
+ * `undefined` for a generation no epoch has proven.
445
+ *
446
+ * Set when the generation is compiled, and again whenever a later epoch's
447
+ * first delivery proves the whole generation still matches the filesystem.
448
+ * While it equals the cache's current epoch, each module's first delivery is
449
+ * settled by the supplied source alone, exactly as it was when every pass
450
+ * compiled its own generation (samchon/ttsc#1300).
451
+ */
452
+ deliveryEpoch?: number;
453
+ /**
454
+ * Whether this generation's non-error diagnostics have been surfaced at all,
455
+ * and the epoch they were last surfaced in.
456
+ *
457
+ * The diagnostics describe one compile of one program, so they belong to the
458
+ * generation rather than to a delivery; a pass that reuses a retained
459
+ * generation still surfaces them once, because a build's warnings are part of
460
+ * what that build reports (samchon/ttsc#1304). The two fields are separate so
461
+ * a persistent host, whose epoch is `undefined`, still reports the first
462
+ * time.
463
+ */
464
+ diagnosticsReported?: boolean;
465
+ diagnosticsEpoch?: number;
196
466
  /**
197
467
  * Files already delivered from this generation, keyed by filesystem identity.
198
- * Build-scoped caches use this to skip persistent validation only for a
199
- * module's first delivery inside the current build.
468
+ * A cache with a delivery epoch uses this to skip persistent validation only
469
+ * for a module's first delivery inside the current pass; the set is cleared
470
+ * whenever a new epoch's gate re-proves the generation.
200
471
  */
201
472
  servedFiles?: Set<string>;
473
+ /**
474
+ * Absolute path of the adapter-owned scratch directory used for this
475
+ * generation. It is disposed after compilation, so none of its compiler,
476
+ * resolver, or plugin artifacts can be a persistent cache or watch input.
477
+ */
478
+ scratchDirectory?: string;
202
479
  /**
203
480
  * Absolute path of the generated temp-dir tsconfig this compile ran against,
204
481
  * when an alias/compiler-options overlay required one. The compiler reports
205
482
  * it in the envelope's `graph.configs` chain, but it is disposed right after
206
483
  * the compile, so registering it as a watch input would invalidate every
207
- * bundler cache snapshot on the next build; watch derivation must skip
208
- * exactly this path.
484
+ * bundler cache snapshot on the next build; watch derivation must skip this
485
+ * path. {@link scratchDirectory} owns the wider disposable-input bound.
209
486
  */
210
487
  temporaryTsconfig?: string;
211
488
  }
@@ -284,10 +561,30 @@ const TRANSFORM_RESULT_FILESYSTEM = new WeakMap<
284
561
  >();
285
562
 
286
563
  /**
287
- * Caches whose owner has declared a real per-build lifecycle by calling
288
- * {@link beginTtscTransformBuild} before transforms begin.
564
+ * The current delivery epoch of each cache whose owner has declared a real
565
+ * per-pass lifecycle by calling {@link beginTtscTransformBuild}.
566
+ *
567
+ * A _delivery epoch_ is one bundler pass: the window inside which each module
568
+ * is requested at most once, so its first delivery may be settled against the
569
+ * state the pass started from. It is deliberately not the same fact as whether
570
+ * the generation is still valid, which the recorded snapshot answers.
571
+ * Conflating the two is what made every host with a repeating `buildStart` —
572
+ * webpack and Rspack watch, Rollup and Rolldown watch, `vite build --watch`,
573
+ * esbuild rebuild — discard a perfectly good whole-project compile on every
574
+ * edit (samchon/ttsc#1300).
575
+ *
576
+ * Absent from the map means persistent validation: a host with no pass boundary
577
+ * at all (a watching Vite dev server, Metro, the Turbopack loader), where every
578
+ * delivery proves the generation for itself.
289
579
  */
290
- const BUILD_SCOPED_TRANSFORM_CACHES = new WeakSet<TtscTransformCache>();
580
+ const TRANSFORM_CACHE_EPOCHS = new WeakMap<TtscTransformCache, number>();
581
+
582
+ /** The pass a delivery belongs to, or `undefined` under persistent validation. */
583
+ function transformCacheEpoch(
584
+ cache: TtscTransformCache | undefined,
585
+ ): number | undefined {
586
+ return cache === undefined ? undefined : TRANSFORM_CACHE_EPOCHS.get(cache);
587
+ }
291
588
 
292
589
  function createHostPathIdentityContext(
293
590
  filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
@@ -350,28 +647,41 @@ function resultFilesystem(
350
647
  }
351
648
 
352
649
  /**
353
- * Start a host build, clearing its prior generation and enabling constant-time
354
- * first delivery for modules compiled during this build.
650
+ * Open a new delivery pass, enabling constant-time first delivery for every
651
+ * module this pass asks for.
652
+ *
653
+ * This deliberately retains the cached generation. The pass boundary is a
654
+ * statement about _deliveries_ — each module is requested at most once inside
655
+ * it — not about whether the compiled program is still correct, which the
656
+ * generation's own recorded snapshot answers and which
657
+ * {@link matchesCachedSource} proves once at the pass's first delivery. Clearing
658
+ * here instead made a host whose `buildStart` repeats recompile the whole
659
+ * project on every rebuild even when no compiler input had changed
660
+ * (samchon/ttsc#1300). Use {@link resetTtscTransformCache} to actually discard a
661
+ * generation and its watchers.
355
662
  *
356
- * Hosts without a guaranteed build-start callback use persistent validation
357
- * unless they have another immutable lifecycle. Bun runtime setup, for example,
663
+ * Hosts without a guaranteed pass boundary use persistent validation unless
664
+ * they have another immutable lifecycle. Bun runtime setup, for example,
358
665
  * defines one process-scoped module-loading session.
359
666
  */
360
667
  export function beginTtscTransformBuild(cache: TtscTransformCache): void {
361
- clearTtscTransformCache(cache);
362
- BUILD_SCOPED_TRANSFORM_CACHES.add(cache);
668
+ TRANSFORM_CACHE_EPOCHS.set(
669
+ cache,
670
+ (TRANSFORM_CACHE_EPOCHS.get(cache) ?? 0) + 1,
671
+ );
363
672
  }
364
673
 
365
674
  /**
366
- * Clear a cache and return it to persistent validation mode.
675
+ * Discard every generation, dispose its watchers, and return the cache to
676
+ * persistent validation mode.
367
677
  *
368
- * This is distinct from {@link beginTtscTransformBuild}: hosts such as Vite's
369
- * development server may invoke `buildStart` only once for a process that spans
370
- * many edits, so that callback cannot authorize build-scoped shortcuts.
678
+ * This is the unconditional lifecycle boundary, and it is distinct from
679
+ * {@link beginTtscTransformBuild}: a pass ending is not a reason to throw a
680
+ * proven compile away, while a session ending is.
371
681
  */
372
682
  export function resetTtscTransformCache(cache: TtscTransformCache): void {
373
683
  clearTtscTransformCache(cache);
374
- BUILD_SCOPED_TRANSFORM_CACHES.delete(cache);
684
+ TRANSFORM_CACHE_EPOCHS.delete(cache);
375
685
  }
376
686
 
377
687
  /** Dispose generation-owned filesystem resources before clearing a cache. */
@@ -486,10 +796,35 @@ export async function transformTtsc(
486
796
  });
487
797
 
488
798
  for (;;) {
799
+ // Read once per iteration, before the cache is consulted, so a delivery
800
+ // belongs to the pass that was current when it started examining the
801
+ // generation. A pass opened while this one awaits an in-flight compile is
802
+ // picked up by the next iteration, which is the one that runs when the
803
+ // entry it awaited turns out to have been superseded.
804
+ const epoch = transformCacheEpoch(cache);
489
805
  let transformed = cache?.get(key);
490
806
  if (transformed !== undefined) {
491
- // A rejected in-flight generation must not stay cached: evict it (only if
492
- // it is still the current entry) so a later call re-runs the transform.
807
+ const terminal = TERMINAL_TRANSFORM_GENERATIONS.get(transformed);
808
+ if (terminal !== undefined) {
809
+ // A terminal verdict is an answer about one observed environment, not an
810
+ // invitation for every later module to repeat the whole compile.
811
+ if (
812
+ replaysTerminalGeneration(terminal, epoch, {
813
+ currentFile: file,
814
+ currentSource: source,
815
+ filesystem,
816
+ })
817
+ ) {
818
+ throw terminal;
819
+ }
820
+ evictGeneration(cache, key, transformed);
821
+ if (cache?.get(key) !== undefined) {
822
+ continue;
823
+ }
824
+ transformed = undefined;
825
+ }
826
+ }
827
+ if (transformed !== undefined) {
493
828
  const cached = await awaitOrEvict(cache, key, transformed);
494
829
  TRANSFORM_RESULT_FILESYSTEM.set(cached.result, filesystem);
495
830
  // While this caller awaited the old Promise, another caller may have
@@ -497,9 +832,7 @@ export async function transformTtsc(
497
832
  if (cache?.get(key) !== transformed) {
498
833
  continue;
499
834
  }
500
- const buildScoped =
501
- cache !== undefined && BUILD_SCOPED_TRANSFORM_CACHES.has(cache);
502
- if (!buildScoped) {
835
+ if (epoch === undefined) {
503
836
  await settleProjectMutationEvents(cached);
504
837
  if (cache?.get(key) !== transformed) {
505
838
  continue;
@@ -514,16 +847,33 @@ export async function transformTtsc(
514
847
  projectRoot: cached.projectRoot,
515
848
  result: cached.result,
516
849
  }) &&
517
- matchesCachedSource(cached, file, source, buildScoped)
850
+ matchesCachedSource(cached, file, source, epoch)
518
851
  ) {
519
- reportSuccessDiagnostics(cached.result);
852
+ reportSuccessDiagnostics(cached, epoch);
520
853
  // A resolved `"exception"` / `"failure"` envelope makes this throw;
521
- // that is a failed generation too, so evict before surfacing it.
522
- const code = selectOrEvict(cache, key, transformed, {
523
- file,
524
- projectRoot: cached.projectRoot,
525
- result: cached.result,
526
- });
854
+ // that is a failed generation too, so it is retained for this pass or
855
+ // evicted outside one before being surfaced.
856
+ let code: string;
857
+ try {
858
+ code = selectOrEvict(cache, key, transformed, epoch, {
859
+ file,
860
+ projectRoot: cached.projectRoot,
861
+ result: cached.result,
862
+ tsconfig: cached.tsconfig,
863
+ });
864
+ } catch (error) {
865
+ if (!(error instanceof TtscMissingProgramOutputError)) {
866
+ notifyFailedGenerationInputs(hooks, cached);
867
+ throw error;
868
+ }
869
+ // The compile is fine and simply has nothing for this module, so the
870
+ // module goes back to the host untransformed rather than failing the
871
+ // build (samchon/ttsc#1308). It still counts as delivered in this
872
+ // pass, and there is nothing to watch for a file with no output.
873
+ reportMissingProgramOutput(cached, error, epoch);
874
+ markCachedSourceServed(cached, file);
875
+ return undefined;
876
+ }
527
877
  notifyWatchInputs(hooks, cached, file);
528
878
  markCachedSourceServed(cached, file);
529
879
  return createTransformResult(source, code);
@@ -544,6 +894,10 @@ export async function transformTtsc(
544
894
  compilerOptions: options.compilerOptions,
545
895
  currentFile: file,
546
896
  currentSource: source,
897
+ // Stamp the pass this compile was started for, not the one it happens
898
+ // to finish in: a boundary crossed mid-compile leaves the generation
899
+ // belonging to the earlier pass, so the next pass re-proves it.
900
+ deliveryEpoch: epoch,
547
901
  filesystem,
548
902
  plugins: options.plugins,
549
903
  trackProjectMembership: cache !== undefined,
@@ -557,12 +911,24 @@ export async function transformTtsc(
557
911
  continue;
558
912
  }
559
913
  const { projectRoot, result } = cached;
560
- reportSuccessDiagnostics(result);
561
- const code = selectOrEvict(cache, key, generation, {
562
- file,
563
- projectRoot,
564
- result,
565
- });
914
+ reportSuccessDiagnostics(cached, epoch);
915
+ let code: string;
916
+ try {
917
+ code = selectOrEvict(cache, key, generation, epoch, {
918
+ file,
919
+ projectRoot,
920
+ result,
921
+ tsconfig: cached.tsconfig,
922
+ });
923
+ } catch (error) {
924
+ if (!(error instanceof TtscMissingProgramOutputError)) {
925
+ notifyFailedGenerationInputs(hooks, cached);
926
+ throw error;
927
+ }
928
+ reportMissingProgramOutput(cached, error, epoch);
929
+ markCachedSourceServed(cached, file);
930
+ return undefined;
931
+ }
566
932
  notifyWatchInputs(hooks, cached, file);
567
933
  markCachedSourceServed(cached, file);
568
934
  if (
@@ -575,13 +941,15 @@ export async function transformTtsc(
575
941
  }
576
942
 
577
943
  /**
578
- * Await a cached generation, evicting it on rejection.
944
+ * Await a cached generation, retaining only terminal proof failures.
579
945
  *
580
946
  * The cache stores the in-flight transform Promise before it settles so
581
- * concurrent callers share one compilation. A rejected generation must not
582
- * remain the authoritative cached result, or a transient toolchain/host failure
583
- * becomes permanent for a long-lived worker. Eviction is identity-guarded so a
584
- * newer generation another caller installed under the same key survives.
947
+ * concurrent callers share one compilation. Ordinary compiler and host
948
+ * rejections are evicted so a transient failure cannot become permanent. A
949
+ * bounded stabilization failure is different: it already spent its retry and
950
+ * repeating it for every later module recreates the issue this gate prevents.
951
+ * It stays authoritative until its retained input baseline changes or the cache
952
+ * owner starts a new lifecycle.
585
953
  */
586
954
  async function awaitOrEvict(
587
955
  cache: TtscTransformCache | undefined,
@@ -591,35 +959,159 @@ async function awaitOrEvict(
591
959
  try {
592
960
  return await generation;
593
961
  } catch (error) {
594
- evictGeneration(cache, key, generation);
962
+ if (
963
+ error instanceof TtscUnstableGenerationError &&
964
+ cache?.get(key) === generation
965
+ ) {
966
+ TERMINAL_TRANSFORM_GENERATIONS.set(generation, error);
967
+ } else {
968
+ evictGeneration(cache, key, generation);
969
+ }
595
970
  throw error;
596
971
  }
597
972
  }
598
973
 
599
974
  /**
600
- * Extract the transformed source, evicting the generation when the result is a
601
- * host `"exception"` or compiler `"failure"` (which makes
602
- * {@link selectTransformedSource} throw). Such a failed generation must not be
603
- * replayed to later callers of an unchanged module.
975
+ * Extract the transformed source, and decide what a throwing generation is.
976
+ *
977
+ * {@link selectTransformedSource} throws for two different reasons, and only one
978
+ * of them is about the generation. A host `"exception"` or a compiler
979
+ * `"failure"` means the compile produced nothing for anyone: inside a pass that
980
+ * verdict is retained and replayed, because evicting it made every remaining
981
+ * module repeat the whole-project transform only to reach the identical answer
982
+ * (samchon/ttsc#1303), and outside a pass it keeps being evicted so a
983
+ * long-lived worker retries on its very next delivery exactly as before.
984
+ *
985
+ * A `"success"` envelope that has no output for the module asking is the other
986
+ * reason, and it is a fact about that one file: an ordinary condition for a
987
+ * module the bundle reaches and the tsconfig program does not contain. It is
988
+ * neither retained nor evicted. The error reaches the caller, and the
989
+ * generation, which compiled perfectly well for every other module, stays.
604
990
  */
605
991
  function selectOrEvict(
606
992
  cache: TtscTransformCache | undefined,
607
993
  key: string,
608
994
  generation: Promise<TtscCachedProjectTransform>,
995
+ epoch: number | undefined,
609
996
  props: {
610
997
  file: string;
611
998
  projectRoot: string;
612
999
  result: ITtscCompilerTransformation;
1000
+ tsconfig: string;
613
1001
  },
614
1002
  ): string {
615
1003
  try {
616
1004
  return selectTransformedSource(props);
617
1005
  } catch (error) {
618
- evictGeneration(cache, key, generation);
1006
+ const verdict = retainPassVerdict(
1007
+ cache,
1008
+ key,
1009
+ generation,
1010
+ epoch,
1011
+ props.result,
1012
+ error,
1013
+ );
1014
+ if (verdict !== undefined) {
1015
+ throw verdict;
1016
+ }
1017
+ // A generation that compiled fine and simply has no output for the module
1018
+ // asking is not a failed generation. Discarding it made every later module
1019
+ // recompile the whole project to reach the same answer, which is the cost
1020
+ // samchon/ttsc#1303 is about, for a bundle that merely reaches a file the
1021
+ // tsconfig program does not contain.
1022
+ if (props.result.type !== "success") {
1023
+ evictGeneration(cache, key, generation);
1024
+ }
619
1025
  throw error;
620
1026
  }
621
1027
  }
622
1028
 
1029
+ /**
1030
+ * Retain the verdict of a compile this pass already attempted, or return
1031
+ * `undefined` when nothing may be retained.
1032
+ *
1033
+ * Only inside a delivery pass. A pass is the window in which every delivery is
1034
+ * settled against the state the pass started from, so an attempt it already
1035
+ * made is part of that state and the remaining modules replay it rather than
1036
+ * each repeating a whole-project transform to reach the same answer. Outside a
1037
+ * pass there is no such window, and a long-lived worker must keep retrying on
1038
+ * its very next delivery so a transient host failure never becomes permanent.
1039
+ */
1040
+ function retainPassVerdict(
1041
+ cache: TtscTransformCache | undefined,
1042
+ key: string,
1043
+ generation: Promise<TtscCachedProjectTransform>,
1044
+ epoch: number | undefined,
1045
+ result: ITtscCompilerTransformation,
1046
+ error: unknown,
1047
+ ): TtscTerminalGenerationError | undefined {
1048
+ // Only an envelope that failed outright is a statement about the generation.
1049
+ // `selectTransformedSource` also throws for a file the compile simply has no
1050
+ // output for, which is an ordinary condition for a module the bundle reaches
1051
+ // but the tsconfig program does not contain, and which says nothing about the
1052
+ // other modules. Retaining that would fail the whole pass, naming a file none
1053
+ // of them asked about.
1054
+ if (
1055
+ result.type === "success" ||
1056
+ epoch === undefined ||
1057
+ cache?.get(key) !== generation
1058
+ ) {
1059
+ return undefined;
1060
+ }
1061
+ const existing = TERMINAL_TRANSFORM_GENERATIONS.get(generation);
1062
+ if (existing !== undefined) {
1063
+ return existing;
1064
+ }
1065
+ const verdict = new TtscPassVerdictError(error, epoch);
1066
+ TERMINAL_TRANSFORM_GENERATIONS.set(generation, verdict);
1067
+ return verdict;
1068
+ }
1069
+
1070
+ /**
1071
+ * Whether a terminal verdict still answers for this delivery.
1072
+ *
1073
+ * Inside the pass that produced or confirmed it, it is replayed without
1074
+ * re-probing anything: the pass settles every delivery against the state it
1075
+ * started from, so re-walking the project once per module would spend exactly
1076
+ * the cost this gate exists to remove.
1077
+ *
1078
+ * Across passes the two kinds part company. A pass verdict is dropped, because
1079
+ * a new pass is the first boundary at which the host itself claims something
1080
+ * may have changed, and the compile it stood for was never proven against a
1081
+ * recorded environment. An unstable generation was, so it keeps its own rule:
1082
+ * one fresh attempt per pass, and otherwise replayed until that recorded
1083
+ * environment provably moves.
1084
+ */
1085
+ function replaysTerminalGeneration(
1086
+ terminal: TtscTerminalGenerationError,
1087
+ epoch: number | undefined,
1088
+ props: {
1089
+ currentFile: string;
1090
+ currentSource: string;
1091
+ filesystem: TtscTransformFilesystemOperations;
1092
+ },
1093
+ ): boolean {
1094
+ if (terminal instanceof TtscPassVerdictError) {
1095
+ // A pass verdict has no recorded environment to re-confirm against, so the
1096
+ // pass that produced it is its whole scope.
1097
+ return epoch !== undefined && terminal.epoch === epoch;
1098
+ }
1099
+ if (!(terminal instanceof TtscUnstableGenerationError)) {
1100
+ return false;
1101
+ }
1102
+ // An unstable generation does have one, and confirming it per delivery is the
1103
+ // behaviour its own contract describes, so the pass does not cache that
1104
+ // answer. A new pass still grants the fresh attempt the per-pass cache clear
1105
+ // used to give it.
1106
+ if (
1107
+ epoch !== undefined &&
1108
+ terminal.validation.cached.deliveryEpoch !== epoch
1109
+ ) {
1110
+ return false;
1111
+ }
1112
+ return !failedGenerationEnvironmentChanged(terminal.validation, props);
1113
+ }
1114
+
623
1115
  /**
624
1116
  * Delete a failed generation from the cache only when it is still the entry
625
1117
  * stored under `key`. The identity check prevents an older failed generation's
@@ -791,6 +1283,8 @@ interface TtscEnvelopeGraphIndexes {
791
1283
  string,
792
1284
  { hash: string | null; path: string; realpath: string | null }
793
1285
  >;
1286
+ /** Native compiler-observation failure for an unproven member identity. */
1287
+ readonly inputProofFailures: Map<string, string>;
794
1288
  /** Aliased graph proof keys that reported contradictory generation states. */
795
1289
  readonly inputProofConflicts: Set<string>;
796
1290
  }
@@ -849,6 +1343,7 @@ function envelopeGraphIndexes(
849
1343
  members: new Set(),
850
1344
  speculative: new Set(),
851
1345
  inputProofs: new Map(),
1346
+ inputProofFailures: new Map(),
852
1347
  inputProofConflicts: new Set(),
853
1348
  };
854
1349
  const graph =
@@ -871,7 +1366,11 @@ function envelopeGraphIndexes(
871
1366
  )
872
1367
  .map((target) => {
873
1368
  const absoluteTarget = path.resolve(props.projectRoot, target);
874
- built.members.add(derivationIdentity(state, absoluteTarget));
1369
+ const targetIdentity = derivationIdentity(state, absoluteTarget);
1370
+ built.members.add(targetIdentity);
1371
+ if (!built.spellings.has(targetIdentity)) {
1372
+ built.spellings.set(targetIdentity, absoluteTarget);
1373
+ }
875
1374
  return absoluteTarget;
876
1375
  }),
877
1376
  );
@@ -880,7 +1379,9 @@ function envelopeGraphIndexes(
880
1379
  built.globals.push(...selectListedFiles(props.projectRoot, graph.globals));
881
1380
  built.configs.push(...selectListedFiles(props.projectRoot, graph.configs));
882
1381
  for (const input of [...built.globals, ...built.configs]) {
883
- built.members.add(derivationIdentity(state, input));
1382
+ const identity = derivationIdentity(state, input);
1383
+ built.members.add(identity);
1384
+ if (!built.spellings.has(identity)) built.spellings.set(identity, input);
884
1385
  }
885
1386
  const candidateEntries = Object.entries(graph.candidates ?? {}).filter(
886
1387
  (entry) => Array.isArray(entry[1]),
@@ -890,9 +1391,12 @@ function envelopeGraphIndexes(
890
1391
  // candidate could be classified speculative before a later entry proves
891
1392
  // the same path is a realized source.
892
1393
  for (const [source] of candidateEntries) {
893
- built.members.add(
894
- derivationIdentity(state, path.resolve(props.projectRoot, source)),
895
- );
1394
+ const absoluteSource = path.resolve(props.projectRoot, source);
1395
+ const identity = derivationIdentity(state, absoluteSource);
1396
+ built.members.add(identity);
1397
+ if (!built.spellings.has(identity)) {
1398
+ built.spellings.set(identity, absoluteSource);
1399
+ }
896
1400
  }
897
1401
  const realized = new Set(built.members);
898
1402
  for (const [source, candidates] of candidateEntries) {
@@ -914,6 +1418,22 @@ function envelopeGraphIndexes(
914
1418
  // only as a candidate.
915
1419
  if (!realized.has(identity)) built.speculative.add(identity);
916
1420
  built.members.add(identity);
1421
+ if (!built.spellings.has(identity)) {
1422
+ built.spellings.set(
1423
+ identity,
1424
+ path.resolve(props.projectRoot, candidate),
1425
+ );
1426
+ }
1427
+ }
1428
+ }
1429
+ const transformSources = new Set<string>();
1430
+ if (props.result.type === "success") {
1431
+ for (const output of Object.keys(props.result.typescript)) {
1432
+ if (!isDeclarationFile(output)) {
1433
+ transformSources.add(
1434
+ derivationIdentity(state, path.resolve(props.projectRoot, output)),
1435
+ );
1436
+ }
917
1437
  }
918
1438
  }
919
1439
  for (const [input, hash] of Object.entries(graph.inputHashes ?? {})) {
@@ -939,7 +1459,9 @@ function envelopeGraphIndexes(
939
1459
  }
940
1460
  const absolute = path.resolve(props.projectRoot, input);
941
1461
  const identity = derivationIdentity(state, absolute);
942
- if (!built.members.has(identity)) continue;
1462
+ if (!built.members.has(identity) && !transformSources.has(identity)) {
1463
+ continue;
1464
+ }
943
1465
  const proof = {
944
1466
  hash,
945
1467
  path: absolute,
@@ -962,6 +1484,25 @@ function envelopeGraphIndexes(
962
1484
  built.inputProofs.set(identity, proof);
963
1485
  }
964
1486
  }
1487
+ for (const [input, reason] of Object.entries(
1488
+ graph.inputProofFailures ?? {},
1489
+ )) {
1490
+ if (typeof reason !== "string" || !/^[a-z0-9-]{1,64}$/.test(reason)) {
1491
+ continue;
1492
+ }
1493
+ const absolute = path.resolve(props.projectRoot, input);
1494
+ const identity = derivationIdentity(state, absolute);
1495
+ if (!built.members.has(identity) && !transformSources.has(identity)) {
1496
+ continue;
1497
+ }
1498
+ if (built.inputProofs.has(identity)) {
1499
+ built.inputProofs.delete(identity);
1500
+ built.inputProofConflicts.add(identity);
1501
+ }
1502
+ if (!built.inputProofFailures.has(identity)) {
1503
+ built.inputProofFailures.set(identity, reason);
1504
+ }
1505
+ }
965
1506
  }
966
1507
  state.graph = built;
967
1508
  return built;
@@ -1018,9 +1559,55 @@ function collectDeclaredIdentities(
1018
1559
  * Envelope keys mirror the `typescript` keys (project-relative); values may be
1019
1560
  * project-relative or absolute. Every path is absolutized against the project
1020
1561
  * root and deduplicated; the file itself is dropped (the bundler already
1021
- * watches the module it transforms), and so is the disposed temp-dir tsconfig
1022
- * (see {@link TtscCachedProjectTransform.temporaryTsconfig}).
1562
+ * watches the module it transforms), and so is every path in the disposed
1563
+ * transform scratch tree (see
1564
+ * {@link TtscCachedProjectTransform.scratchDirectory}).
1565
+ */
1566
+ /**
1567
+ * Register the failed generation's own project inputs so the host can observe
1568
+ * the fix.
1569
+ *
1570
+ * A successful delivery registers the derived watch inputs, which is how a
1571
+ * type-only file that no bundler graph contains still invalidates the modules
1572
+ * depending on it. A failed one used to register nothing: `selectWatchInputs`
1573
+ * returns an empty list for an `"exception"` envelope, and the throw happens
1574
+ * before `notifyWatchInputs` is reached at all. When the failing compile is the
1575
+ * first of a watching session, that leaves no channel through which the fix can
1576
+ * arrive: the user repairs a file the bundler does not track, nothing is
1577
+ * invalidated, and the error stays on screen (samchon/ttsc#1312).
1578
+ *
1579
+ * The generation records the project walk even when the compile failed, so the
1580
+ * files a fix would touch are exactly what it already holds. The cost is paid
1581
+ * only on a failure, and only until the next compile succeeds and narrows the
1582
+ * set back to the derived inputs.
1023
1583
  */
1584
+ function notifyFailedGenerationInputs(
1585
+ hooks: TtscTransformHooks | undefined,
1586
+ cached: TtscCachedProjectTransform,
1587
+ ): void {
1588
+ const addWatchFile = hooks?.addWatchFile;
1589
+ if (addWatchFile === undefined) {
1590
+ return;
1591
+ }
1592
+ for (const key of Object.keys(cached.inputHashes)) {
1593
+ const input = path.resolve(cached.projectRoot, key);
1594
+ if (isTransformScratchInput(input, cached.scratchDirectory)) {
1595
+ continue;
1596
+ }
1597
+ // No evidence argument, deliberately. `missing: false` would be a claim
1598
+ // this path cannot back: a failed generation is replayed for the rest of
1599
+ // its pass without re-proving its inputs, so the walk that recorded them
1600
+ // may be older than the delivery, and one of them having been deleted is a
1601
+ // live reason for that compile to have failed. Letting the adapter probe
1602
+ // also routes an absent input to the missing-input poll, which is the only
1603
+ // channel through which restoring it can invalidate anything: a bundler
1604
+ // watch on a path that does not exist registers nothing, and no module
1605
+ // graph carries a type-only input. It costs one `existsSync` per input,
1606
+ // and only where the adapter reads evidence at all, which is Vite serve.
1607
+ addWatchFile(input);
1608
+ }
1609
+ }
1610
+
1024
1611
  function notifyWatchInputs(
1025
1612
  hooks: TtscTransformHooks | undefined,
1026
1613
  cached: TtscCachedProjectTransform,
@@ -1036,6 +1623,7 @@ function notifyWatchInputs(
1036
1623
  file,
1037
1624
  projectRoot: cached.projectRoot,
1038
1625
  result: cached.result,
1626
+ scratchDirectory: cached.scratchDirectory,
1039
1627
  temporaryTsconfig: cached.temporaryTsconfig,
1040
1628
  })) {
1041
1629
  // Hand the adapter the identity this generation already resolved and the
@@ -1078,6 +1666,7 @@ function selectWatchInputs(props: {
1078
1666
  file: string;
1079
1667
  projectRoot: string;
1080
1668
  result: ITtscCompilerTransformation;
1669
+ scratchDirectory?: string;
1081
1670
  temporaryTsconfig?: string;
1082
1671
  }): string[] {
1083
1672
  if (props.result.type === "exception") {
@@ -1101,6 +1690,7 @@ function deriveWatchInputs(
1101
1690
  file: string;
1102
1691
  projectRoot: string;
1103
1692
  result: ITtscCompilerTransformation;
1693
+ scratchDirectory?: string;
1104
1694
  temporaryTsconfig?: string;
1105
1695
  },
1106
1696
  fileIdentity: string,
@@ -1123,6 +1713,7 @@ function deriveWatchInputs(
1123
1713
  if (
1124
1714
  spelling === currentSpelling ||
1125
1715
  spelling === temporarySpelling ||
1716
+ isTransformScratchInput(spelling, props.scratchDirectory) ||
1126
1717
  lexicalSeen.has(spelling)
1127
1718
  ) {
1128
1719
  return;
@@ -1132,6 +1723,7 @@ function deriveWatchInputs(
1132
1723
  output.push(input);
1133
1724
  };
1134
1725
  const appendPhysical = (input: string): void => {
1726
+ if (isTransformScratchInput(input, props.scratchDirectory)) return;
1135
1727
  const identity = derivationIdentity(state, input);
1136
1728
  if (excluded.has(identity) || physicalSeen.has(identity)) return;
1137
1729
  physicalSeen.add(identity);
@@ -1462,11 +2054,24 @@ export function stripQuery(id: string): string {
1462
2054
  }
1463
2055
 
1464
2056
  /**
1465
- * Returns `true` for TypeScript declaration files (`.d.ts`, `.d.mts`,
1466
- * `.d.cts`).
2057
+ * Returns `true` for every declaration-file spelling TypeScript-Go accepts.
2058
+ * Besides the standard `.d.ts`, `.d.mts`, and `.d.cts` forms, TypeScript-Go
2059
+ * treats an arbitrary-extension source such as `styles.d.css.ts` as a
2060
+ * declaration file too.
1467
2061
  */
1468
2062
  export function isDeclarationFile(id: string): boolean {
1469
- return id.endsWith(".d.ts") || id.endsWith(".d.mts") || id.endsWith(".d.cts");
2063
+ // Module ids can cross process/platform boundaries (for example, a Windows
2064
+ // id inspected by a POSIX host). TypeScript-Go normalizes both separators
2065
+ // before taking the basename, so a `.d.` directory component must not turn
2066
+ // an ordinary source into a declaration file.
2067
+ const normalized = id.replaceAll("\\", "/");
2068
+ const base = normalized.slice(normalized.lastIndexOf("/") + 1);
2069
+ return (
2070
+ base.endsWith(".d.ts") ||
2071
+ base.endsWith(".d.mts") ||
2072
+ base.endsWith(".d.cts") ||
2073
+ (base.endsWith(".ts") && base.includes(".d."))
2074
+ );
1470
2075
  }
1471
2076
 
1472
2077
  /**
@@ -1502,32 +2107,50 @@ export function createTransformResult(
1502
2107
  * state.
1503
2108
  *
1504
2109
  * Always compares the current module's in-memory source with the generation
1505
- * snapshot. A cache whose owner called {@link beginTtscTransformBuild} can use
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
2110
+ * snapshot. A cache with a delivery epoch can use that comparison alone for a
2111
+ * stable generation's first delivery of each module in the current pass, once
2112
+ * the pass's own first delivery has proven the whole generation still matches
2113
+ * the filesystem. An incomplete generation may not take this shortcut:
2114
+ * otherwise a sibling output captured during a filesystem race could still be
2115
+ * served once. Later graph-bearing requests validate the file's derived input
2116
+ * set and project membership; graph-free envelopes conservatively re-hash the
2117
+ * complete project and out-of-walk snapshots. Any mismatch forces a complete
1512
2118
  * re-transform.
1513
2119
  */
1514
2120
  function matchesCachedSource(
1515
2121
  cached: TtscCachedProjectTransform,
1516
2122
  file: string,
1517
2123
  source: string,
1518
- buildScoped: boolean,
2124
+ epoch: number | undefined,
1519
2125
  ): boolean {
1520
2126
  const identities = envelopeDerivation(cached).identityContext;
1521
2127
  const currentKey = toProjectKey(cached.projectRoot, file, identities);
1522
- if (cached.inputHashes[currentKey] !== hashText(source)) {
2128
+ const identity = pathIdentityKey(file, identities);
2129
+ const expected =
2130
+ cached.sourceHashes?.[identity] ??
2131
+ cached.inputHashes[currentKey] ??
2132
+ cached.externalInputHashes?.[identity];
2133
+ if (expected !== hashText(source)) {
1523
2134
  return false;
1524
2135
  }
1525
- if (
1526
- buildScoped &&
1527
- cached.projectSnapshotComplete === true &&
1528
- !cached.servedFiles?.has(pathIdentityKey(file, identities))
1529
- ) {
1530
- return true;
2136
+ if (epoch !== undefined && cached.projectSnapshotComplete === true) {
2137
+ if (cached.deliveryEpoch !== epoch) {
2138
+ // The pass's first delivery. The generation was settled against an
2139
+ // earlier pass, so prove the whole of it once — every input the envelope
2140
+ // declares, the directory membership, the universal host inputs, and the
2141
+ // out-of-walk snapshot — before any of this pass's deliveries may be
2142
+ // settled against it. That proof is what a per-pass recompile used to buy
2143
+ // (samchon/ttsc#1300), at a walk instead of a compile.
2144
+ if (!matchesCompleteInputSnapshot(cached, currentKey, source)) {
2145
+ return false;
2146
+ }
2147
+ cached.deliveryEpoch = epoch;
2148
+ cached.servedFiles?.clear();
2149
+ return true;
2150
+ }
2151
+ if (!cached.servedFiles?.has(identity)) {
2152
+ return true;
2153
+ }
1531
2154
  }
1532
2155
  if (
1533
2156
  cached.result.type !== "exception" &&
@@ -1583,6 +2206,7 @@ function matchesNarrowPersistentInputs(
1583
2206
  file,
1584
2207
  projectRoot: cached.projectRoot,
1585
2208
  result: cached.result,
2209
+ scratchDirectory: cached.scratchDirectory,
1586
2210
  temporaryTsconfig: cached.temporaryTsconfig,
1587
2211
  });
1588
2212
  for (const input of inputs) {
@@ -1839,9 +2463,13 @@ function isMissingPathError(error: unknown): boolean {
1839
2463
  function captureUniversalHostInputValidation(
1840
2464
  cached: TtscCachedProjectTransform,
1841
2465
  currentFile: string,
1842
- ): TtscHostInputValidation | undefined {
2466
+ ): {
2467
+ failures: TtscGenerationProofFailures;
2468
+ validation?: TtscHostInputValidation;
2469
+ } {
1843
2470
  const filesystem = resultFilesystem(cached.result);
1844
2471
  const state = envelopeDerivation(cached);
2472
+ const failures = createGenerationProofFailures();
1845
2473
  const validation: TtscHostInputValidation = {
1846
2474
  entries: new Map(),
1847
2475
  covered: new Set(),
@@ -1851,6 +2479,7 @@ function captureUniversalHostInputValidation(
1851
2479
  filesystem,
1852
2480
  projectRoot: cached.projectRoot,
1853
2481
  result: cached.result,
2482
+ scratchDirectory: cached.scratchDirectory,
1854
2483
  temporaryTsconfig: cached.temporaryTsconfig,
1855
2484
  })) {
1856
2485
  const generationHashes =
@@ -1868,7 +2497,14 @@ function captureUniversalHostInputValidation(
1868
2497
  let readable = false;
1869
2498
  if (expected === undefined) {
1870
2499
  const current = path.resolve(currentFile);
1871
- if (path.resolve(input) !== current) return undefined;
2500
+ if (path.resolve(input) !== current) {
2501
+ recordGenerationProofFailure(failures, {
2502
+ domain: "host",
2503
+ kind: "content-proof-missing",
2504
+ path: input,
2505
+ });
2506
+ return { failures };
2507
+ }
1872
2508
  // The current module may be supplied from an unsaved editor buffer. Its
1873
2509
  // generation snapshot is overlaid below from `currentSource`, so a disk
1874
2510
  // fingerprint would be both unavailable and the wrong authority. The
@@ -1877,7 +2513,12 @@ function captureUniversalHostInputValidation(
1877
2513
  } else {
1878
2514
  const current = hostInputStateHash(input, filesystem);
1879
2515
  if (expected !== current) {
1880
- return undefined;
2516
+ recordGenerationProofFailure(failures, {
2517
+ domain: "host",
2518
+ kind: "content-changed",
2519
+ path: input,
2520
+ });
2521
+ return { failures };
1881
2522
  }
1882
2523
  // A path both sides agree they could not read carries no bytes for a
1883
2524
  // signature to stand for. It still belongs in the manifest, so the
@@ -1897,14 +2538,38 @@ function captureUniversalHostInputValidation(
1897
2538
  state.identityContext,
1898
2539
  )
1899
2540
  ) {
1900
- return undefined;
2541
+ recordGenerationProofFailure(failures, {
2542
+ domain: "host",
2543
+ kind: Object.prototype.hasOwnProperty.call(
2544
+ generationRealpaths,
2545
+ absoluteInput,
2546
+ )
2547
+ ? "realpath-changed"
2548
+ : "realpath-proof-missing",
2549
+ path: input,
2550
+ });
2551
+ return { failures };
1901
2552
  }
1902
2553
  }
1903
2554
  validation.covered.add(path.resolve(input));
1904
2555
  const before = inputMetadataEvidence(input, filesystem);
1905
- if (!matchesRecordedInput(cached, input)) return undefined;
2556
+ if (!matchesRecordedInput(cached, input)) {
2557
+ recordGenerationProofFailure(failures, {
2558
+ domain: "host",
2559
+ kind: "snapshot-mismatch",
2560
+ path: input,
2561
+ });
2562
+ return { failures };
2563
+ }
1906
2564
  const after = inputMetadataSignature(input, filesystem);
1907
- if (before?.signature !== after) return undefined;
2565
+ if (before?.signature !== after) {
2566
+ recordGenerationProofFailure(failures, {
2567
+ domain: "host",
2568
+ kind: "changed-during-validation",
2569
+ path: input,
2570
+ });
2571
+ return { failures };
2572
+ }
1908
2573
  if (before !== undefined) {
1909
2574
  // Do not key this manifest by physical identity. A symlink/junction
1910
2575
  // spelling and its selected target deliberately share that identity,
@@ -1924,7 +2589,14 @@ function captureUniversalHostInputValidation(
1924
2589
  const probe = missingPathProbe(input, filesystem);
1925
2590
  if (probe.blocker !== undefined) {
1926
2591
  const signature = inputMetadataSignature(probe.blocker, filesystem);
1927
- if (signature === undefined) return undefined;
2592
+ if (signature === undefined) {
2593
+ recordGenerationProofFailure(failures, {
2594
+ domain: "host",
2595
+ kind: "blocker-metadata-unavailable",
2596
+ path: probe.blocker,
2597
+ });
2598
+ return { failures };
2599
+ }
1928
2600
  // A blocker proves a kind and an identity, not content: it is the
1929
2601
  // non-directory ancestor that makes everything below it unreachable, and
1930
2602
  // it cannot stop being that without its metadata moving. So it keeps a
@@ -1956,7 +2628,7 @@ function captureUniversalHostInputValidation(
1956
2628
  );
1957
2629
  }
1958
2630
  cached.hostInputValidation = validation;
1959
- return validation;
2631
+ return { failures, validation };
1960
2632
  }
1961
2633
 
1962
2634
  /**
@@ -2335,6 +3007,12 @@ function matchesCompleteInputSnapshot(
2335
3007
  cached.inputSignatures === undefined
2336
3008
  ? undefined
2337
3009
  : { hashes: cached.inputHashes, signatures: cached.inputSignatures },
3010
+ {
3011
+ // Judge membership by the rule the compile ran under, and read only the
3012
+ // inputs this comparison actually consults.
3013
+ declaredKeys: declaredInputs,
3014
+ policy: cached.membershipPolicy,
3015
+ },
2338
3016
  );
2339
3017
  if (!walkSnapshotComplete(current, declaredInputs)) {
2340
3018
  return false;
@@ -2347,7 +3025,9 @@ function matchesCompleteInputSnapshot(
2347
3025
  ) {
2348
3026
  return false;
2349
3027
  }
2350
- current.hashes[currentKey] = hashText(source);
3028
+ if (Object.prototype.hasOwnProperty.call(cached.inputHashes, currentKey)) {
3029
+ current.hashes[currentKey] = hashText(source);
3030
+ }
2351
3031
  if (!sameHashes(cached.inputHashes, current.hashes, declaredInputs)) {
2352
3032
  return false;
2353
3033
  }
@@ -2430,16 +3110,17 @@ function matchesExternalInputRealpaths(
2430
3110
 
2431
3111
  /**
2432
3112
  * 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.
3113
+ * earlier graph. Graph members and out-of-walk transformed sources must carry
3114
+ * compiler-time proof and still match it now; plugin-declared dependency-only
3115
+ * paths retain the historical post-compile snapshot because their own protocol
3116
+ * does not claim generation fingerprints.
2437
3117
  */
2438
3118
  function captureExternalInputSnapshot(
2439
3119
  cached: TtscCachedProjectTransform,
2440
3120
  paths: readonly string[],
2441
3121
  ): {
2442
3122
  complete: boolean;
3123
+ failures: TtscGenerationProofFailures;
2443
3124
  hashes: Record<string, string>;
2444
3125
  realpaths: Record<string, string | null>;
2445
3126
  signatures: Record<string, string>;
@@ -2447,9 +3128,23 @@ function captureExternalInputSnapshot(
2447
3128
  const state = envelopeDerivation(cached);
2448
3129
  const filesystem = resultFilesystem(cached.result);
2449
3130
  const graph = envelopeGraphIndexes(state, cached);
3131
+ // A non-declaration transform output is a compiler-realized source even when
3132
+ // a malformed or legacy graph omitted its node. Its output was computed from
3133
+ // compiler-time bytes, so a post-compile host read cannot prove coherence.
3134
+ const transformSources = new Set<string>();
3135
+ if (cached.result.type === "success") {
3136
+ for (const output of Object.keys(cached.result.typescript)) {
3137
+ if (!isDeclarationFile(output)) {
3138
+ transformSources.add(
3139
+ derivationIdentity(state, path.resolve(cached.projectRoot, output)),
3140
+ );
3141
+ }
3142
+ }
3143
+ }
2450
3144
  const hashes: Record<string, string> = {};
2451
3145
  const realpaths: Record<string, string | null> = {};
2452
3146
  const signatures: Record<string, string> = {};
3147
+ const failures = createGenerationProofFailures();
2453
3148
  let complete = true;
2454
3149
  // Sandwich every read between two metadata signatures. Only a signature that
2455
3150
  // survived its own read, and whose stamp's tick the filesystem's clock has
@@ -2471,28 +3166,54 @@ function captureExternalInputSnapshot(
2471
3166
  // through to the recorded-state branch below, the same evidence a
2472
3167
  // plugin-declared dependency path carries. Its absence still invalidates
2473
3168
  // the generation when it appears, because `missing` is recorded state.
3169
+ const realizedTransformSource = transformSources.has(identity);
2474
3170
  const speculativeOnly =
3171
+ !realizedTransformSource &&
2475
3172
  graph.speculative.has(identity) &&
2476
3173
  !graph.inputProofs.has(identity) &&
2477
3174
  !graph.inputProofConflicts.has(identity);
2478
- if (graph.members.has(identity) && !speculativeOnly) {
3175
+ if (
3176
+ (realizedTransformSource || graph.members.has(identity)) &&
3177
+ !speculativeOnly
3178
+ ) {
2479
3179
  const proof = graph.inputProofs.get(identity);
2480
3180
  if (proof === undefined || graph.inputProofConflicts.has(identity)) {
2481
3181
  complete = false;
3182
+ recordGenerationProofFailure(failures, {
3183
+ domain: "external",
3184
+ kind: graph.inputProofConflicts.has(identity)
3185
+ ? "graph-proof-conflict"
3186
+ : "graph-proof-missing",
3187
+ detail: graph.inputProofFailures.get(identity),
3188
+ path: input,
3189
+ });
2482
3190
  continue;
2483
3191
  }
2484
3192
  const before = inputMetadataEvidence(input, filesystem);
2485
3193
  const currentHash = graphInputStateHash(input, filesystem);
3194
+ const currentRealpath = hostInputRealpath(input, filesystem);
2486
3195
  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
- ) {
3196
+ const realpathMatches = sameHostInputRealpath(
3197
+ proof.realpath,
3198
+ currentRealpath,
3199
+ state.identityContext,
3200
+ );
3201
+ if (currentHash !== proof.hash || !realpathMatches) {
2495
3202
  complete = false;
3203
+ if (currentHash !== proof.hash) {
3204
+ recordGenerationProofFailure(failures, {
3205
+ domain: "external",
3206
+ kind: "graph-content-changed",
3207
+ path: input,
3208
+ });
3209
+ }
3210
+ if (!realpathMatches) {
3211
+ recordGenerationProofFailure(failures, {
3212
+ domain: "external",
3213
+ kind: "graph-realpath-changed",
3214
+ path: input,
3215
+ });
3216
+ }
2496
3217
  } else if (currentHash !== null) {
2497
3218
  // The recorded hash is the compiler's own proof, so a signature may
2498
3219
  // only stand for it once the current bytes were shown to match it.
@@ -2510,32 +3231,36 @@ function captureExternalInputSnapshot(
2510
3231
  hashes[identity] = hash ?? MISSING_INPUT_STATE;
2511
3232
  if (hash !== null) record(input, before, after);
2512
3233
  }
2513
- return { complete, hashes, realpaths, signatures };
3234
+ return { complete, failures, hashes, realpaths, signatures };
2514
3235
  }
2515
3236
 
2516
- /** Verify every graph member still has the state read by the compiler. */
2517
- function matchesCompilerGraphInputProofs(
3237
+ /** Explain every graph member that no longer matches the compiler's state. */
3238
+ function compilerGraphInputProofFailures(
2518
3239
  cached: TtscCachedProjectTransform,
2519
- ): boolean {
3240
+ ): TtscGenerationProofFailures {
3241
+ const failures = createGenerationProofFailures();
2520
3242
  if (
2521
3243
  cached.result.type === "exception" ||
2522
3244
  cached.result.graph === undefined ||
2523
3245
  (cached.result.graph.inputHashes === undefined &&
2524
- cached.result.graph.inputRealpaths === undefined)
3246
+ cached.result.graph.inputRealpaths === undefined &&
3247
+ cached.result.graph.inputProofFailures === undefined)
2525
3248
  ) {
2526
3249
  // Legacy sidecars remain compatible for ordinary in-project graphs. Their
2527
3250
  // out-of-walk members are still rejected by captureExternalInputSnapshot,
2528
3251
  // where a post-compile snapshot cannot prove the compiler's generation.
2529
- return true;
3252
+ return failures;
2530
3253
  }
2531
3254
  const state = envelopeDerivation(cached);
2532
3255
  const filesystem = resultFilesystem(cached.result);
2533
3256
  const graph = envelopeGraphIndexes(state, cached);
2534
- if (graph.inputProofConflicts.size !== 0) {
2535
- return false;
2536
- }
2537
3257
  for (const identity of graph.members) {
2538
3258
  const proof = graph.inputProofs.get(identity);
3259
+ const spelling =
3260
+ proof?.path ?? graph.spellings.get(identity) ?? cached.projectRoot;
3261
+ if (isTransformScratchInput(spelling, cached.scratchDirectory)) {
3262
+ continue;
3263
+ }
2539
3264
  // A speculative candidate has no compile-time read to prove. Requiring one
2540
3265
  // would void every generation of every project whose resolution passes over
2541
3266
  // a higher-priority spelling, which is every project with a dependency
@@ -2544,19 +3269,47 @@ function matchesCompilerGraphInputProofs(
2544
3269
  if (proof === undefined && graph.speculative.has(identity)) {
2545
3270
  continue;
2546
3271
  }
3272
+ if (graph.inputProofConflicts.has(identity)) {
3273
+ recordGenerationProofFailure(failures, {
3274
+ domain: "graph",
3275
+ kind: "proof-conflict",
3276
+ detail: graph.inputProofFailures.get(identity),
3277
+ path: spelling,
3278
+ });
3279
+ continue;
3280
+ }
3281
+ if (proof === undefined) {
3282
+ recordGenerationProofFailure(failures, {
3283
+ domain: "graph",
3284
+ kind: "proof-missing",
3285
+ detail: graph.inputProofFailures.get(identity),
3286
+ path: spelling,
3287
+ });
3288
+ continue;
3289
+ }
3290
+ const currentHash = graphInputStateHash(proof.path, filesystem);
3291
+ if (currentHash !== proof.hash) {
3292
+ recordGenerationProofFailure(failures, {
3293
+ domain: "graph",
3294
+ kind: "content-changed",
3295
+ path: proof.path,
3296
+ });
3297
+ }
2547
3298
  if (
2548
- proof === undefined ||
2549
- graphInputStateHash(proof.path, filesystem) !== proof.hash ||
2550
3299
  !sameHostInputRealpath(
2551
3300
  proof.realpath,
2552
3301
  hostInputRealpath(proof.path, filesystem),
2553
3302
  state.identityContext,
2554
3303
  )
2555
3304
  ) {
2556
- return false;
3305
+ recordGenerationProofFailure(failures, {
3306
+ domain: "graph",
3307
+ kind: "realpath-changed",
3308
+ path: proof.path,
3309
+ });
2557
3310
  }
2558
3311
  }
2559
- return true;
3312
+ return failures;
2560
3313
  }
2561
3314
 
2562
3315
  /** Compare one derived input with the snapshot that owned it at generation. */
@@ -2631,9 +3384,17 @@ export function collectProjectInputHashes(
2631
3384
  projectRoot: string,
2632
3385
  identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
2633
3386
  filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
3387
+ policy?: ITtscProjectMembershipPolicy,
2634
3388
  ): Record<string, string> {
2635
- return collectProjectInputSnapshot(projectRoot, identities, filesystem)
2636
- .hashes;
3389
+ return collectProjectInputSnapshot(
3390
+ projectRoot,
3391
+ identities,
3392
+ filesystem,
3393
+ undefined,
3394
+ {
3395
+ policy,
3396
+ },
3397
+ ).hashes;
2637
3398
  }
2638
3399
 
2639
3400
  /** Hash project files and snapshot the directory topology in one walk. */
@@ -2645,6 +3406,16 @@ function collectProjectInputSnapshot(
2645
3406
  hashes: Record<string, string>;
2646
3407
  signatures: Record<string, string>;
2647
3408
  },
3409
+ options?: {
3410
+ /**
3411
+ * Restrict hashing to these project keys. Supplied by a validating caller,
3412
+ * which compares over exactly this set, and omitted by a capturing one,
3413
+ * which has no generation to compare against yet.
3414
+ */
3415
+ declaredKeys?: ReadonlySet<string>;
3416
+ /** What the resolved configuration admits into the program. */
3417
+ policy?: ITtscProjectMembershipPolicy;
3418
+ },
2648
3419
  ): {
2649
3420
  complete: boolean;
2650
3421
  directoryComplete: boolean;
@@ -2653,19 +3424,34 @@ function collectProjectInputSnapshot(
2653
3424
  projectDirectories: TtscProjectDirectorySnapshot[];
2654
3425
  provenSignatures: Record<string, string>;
2655
3426
  unstableFiles: Set<string>;
3427
+ walkFailures: TtscProjectWalkFailure[];
2656
3428
  } {
2657
3429
  const hashes: Record<string, string> = {};
2658
3430
  const fileSignatures: Record<string, string> = {};
2659
3431
  const provenSignatures: Record<string, string> = {};
2660
3432
  const unstableFiles = new Set<string>();
2661
3433
  let attributed = true;
2662
- const walked = walkProjectInputs(projectRoot, filesystem);
3434
+ const walked = walkProjectInputs(projectRoot, filesystem, options?.policy);
3435
+ const walkFailures = [...walked.failures];
2663
3436
  let complete = walked.complete;
2664
3437
  for (const file of walked.files) {
2665
3438
  try {
2666
- const before = inputMetadataEvidence(file, filesystem);
2667
3439
  const key = toProjectKey(projectRoot, file, identities);
2668
- // A file whose signature still equals the one captured around the read
3440
+ // A caller validating a generation compares hashes over that
3441
+ // generation's declared inputs alone (`sameHashes` takes the declared key
3442
+ // set), so reading anything else is work whose result is never consulted.
3443
+ // Skipping it is what keeps a directory full of emitted files from
3444
+ // costing a read per file on the pass that first sees them
3445
+ // (samchon/ttsc#1307). Capture passes supply no restriction and still
3446
+ // record the whole walk.
3447
+ if (
3448
+ options?.declaredKeys !== undefined &&
3449
+ !options.declaredKeys.has(key)
3450
+ ) {
3451
+ continue;
3452
+ }
3453
+ const before = inputMetadataEvidence(file, filesystem);
3454
+ // A file whose signature still equals the one captured around the read
2669
3455
  // that produced the recorded hash carries that content, so the whole
2670
3456
  // project does not have to be re-read to prove one delivery. A signature
2671
3457
  // that was already proven stays proven: its stamp has not moved since the
@@ -2691,6 +3477,7 @@ function collectProjectInputSnapshot(
2691
3477
  ) {
2692
3478
  complete = false;
2693
3479
  unstableFiles.add(key);
3480
+ walkFailures.push({ kind: "file-changed-during-read", path: file });
2694
3481
  } else {
2695
3482
  fileSignatures[key] = after;
2696
3483
  // Only a signature whose stamp's tick the filesystem's clock provably
@@ -2705,6 +3492,7 @@ function collectProjectInputSnapshot(
2705
3492
  // File watchers may observe a transform while another process is moving
2706
3493
  // or deleting files. The missing key invalidates older cache entries.
2707
3494
  complete = false;
3495
+ walkFailures.push({ kind: "file-read-failed", path: file });
2708
3496
  try {
2709
3497
  unstableFiles.add(toProjectKey(projectRoot, file, identities));
2710
3498
  } catch {
@@ -2722,12 +3510,14 @@ function collectProjectInputSnapshot(
2722
3510
  projectDirectories: walked.directories,
2723
3511
  provenSignatures,
2724
3512
  unstableFiles,
3513
+ walkFailures,
2725
3514
  };
2726
3515
  }
2727
3516
 
2728
3517
  /**
2729
- * Enumerate every regular file under `root`, skipping well-known output and
2730
- * tooling directories (see {@link isIgnoredProjectDirectory}).
3518
+ * Enumerate every regular file under `root`, skipping the directories no
3519
+ * configuration can name ({@link isIgnoredProjectDirectory}) and the ones the
3520
+ * resolved configuration excludes ({@link isExcludedProjectDirectory}).
2731
3521
  *
2732
3522
  * Uses an iterative DFS instead of `fs.readdirSync` recursion to avoid
2733
3523
  * unbounded call-stack depth on deep project trees. The result is sorted so
@@ -2736,20 +3526,36 @@ function collectProjectInputSnapshot(
2736
3526
  function walkProjectInputs(
2737
3527
  root: string,
2738
3528
  filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
3529
+ policy: ITtscProjectMembershipPolicy = PERMISSIVE_PROJECT_MEMBERSHIP_POLICY,
2739
3530
  ): {
2740
3531
  complete: boolean;
2741
3532
  directories: TtscProjectDirectorySnapshot[];
3533
+ failures: TtscProjectWalkFailure[];
2742
3534
  files: string[];
2743
3535
  } {
2744
3536
  let complete = true;
2745
- const directories: TtscProjectDirectorySnapshot[] = [];
3537
+ const failures: TtscProjectWalkFailure[] = [];
2746
3538
  const files: string[] = [];
3539
+ // Collected in one pass, then digested in a second. A directory's digest has
3540
+ // to know whether each child directory can hold program inputs, and the walk
3541
+ // learns that only after descending, so the two cannot be one pass.
3542
+ const visited: {
3543
+ childDirectories: string[];
3544
+ entries: { name: string; kind: string; possible: boolean }[];
3545
+ ownInput: boolean;
3546
+ path: string;
3547
+ stable: string | undefined;
3548
+ }[] = [];
2747
3549
  const stack = [root];
2748
3550
  while (stack.length !== 0) {
2749
3551
  const current = stack.pop()!;
2750
3552
  const before = projectDirectorySignature(current, filesystem);
2751
3553
  if (before === undefined) {
2752
3554
  complete = false;
3555
+ failures.push({
3556
+ kind: "directory-metadata-unavailable",
3557
+ path: current,
3558
+ });
2753
3559
  continue;
2754
3560
  }
2755
3561
  let entries: fs.Dirent[];
@@ -2757,40 +3563,122 @@ function walkProjectInputs(
2757
3563
  entries = filesystem.readdir(current);
2758
3564
  } catch {
2759
3565
  complete = false;
3566
+ failures.push({ kind: "directory-read-failed", path: current });
2760
3567
  continue;
2761
3568
  }
2762
3569
  const after = projectDirectorySignature(current, filesystem);
2763
3570
  if (after === undefined || before !== after) {
2764
3571
  complete = false;
3572
+ failures.push({
3573
+ kind:
3574
+ after === undefined
3575
+ ? "directory-metadata-unavailable"
3576
+ : "directory-changed-during-walk",
3577
+ path: current,
3578
+ });
2765
3579
  }
2766
- directories.push({
3580
+ const visit = {
3581
+ childDirectories: [] as string[],
3582
+ entries: [] as { name: string; kind: string; possible: boolean }[],
3583
+ ownInput: false,
2767
3584
  path: current,
2768
3585
  // If membership moved during enumeration, force the next delivery to
2769
3586
  // replace this generation instead of blessing a torn directory/file
2770
3587
  // snapshot as stable.
2771
- signature:
3588
+ stable:
2772
3589
  after !== undefined && before === after
2773
- ? after
3590
+ ? undefined
2774
3591
  : `unstable:${before}:${after ?? "missing"}`,
2775
- });
3592
+ };
2776
3593
  for (const entry of entries) {
2777
3594
  if (isIgnoredProjectDirectory(entry.name)) {
2778
3595
  continue;
2779
3596
  }
2780
3597
  const file = path.join(current, entry.name);
3598
+ if (entry.isDirectory() && isExcludedProjectDirectory(file, policy)) {
3599
+ continue;
3600
+ }
3601
+ const possible = isPossibleProgramEntry(entry, policy);
3602
+ visit.entries.push({
3603
+ kind: [
3604
+ entry.isDirectory(),
3605
+ entry.isFile(),
3606
+ entry.isSymbolicLink(),
3607
+ ].join(":"),
3608
+ name: entry.name,
3609
+ possible,
3610
+ });
2781
3611
  if (entry.isDirectory()) {
3612
+ visit.childDirectories.push(file);
2782
3613
  stack.push(file);
2783
- } else if (entry.isFile()) {
3614
+ } else if (entry.isFile() && possible) {
3615
+ // Only a file that could enter the program is hashed. A file that
3616
+ // could not is either irrelevant to every generation, or it is one the
3617
+ // compiler actually read, in which case the graph reports it and
3618
+ // `isProjectWalkPath` now agrees it is out of the walk, so it is
3619
+ // recorded and proven by the out-of-walk snapshot instead. Hashing an
3620
+ // emitted tree here bought nothing and cost a read per file, including
3621
+ // in `@ttsc/metro`, whose fingerprint re-keys every transformed file
3622
+ // (samchon/ttsc#1307).
2784
3623
  files.push(file);
3624
+ visit.ownInput = true;
2785
3625
  }
2786
3626
  }
3627
+ visited.push(visit);
3628
+ }
3629
+
3630
+ // A directory matters to program membership only if its subtree can hold a
3631
+ // program input. Propagate that up from the directories that hold one, so a
3632
+ // bundler creating `out/` and filling it with JavaScript a project admitting
3633
+ // none can never compile is not a membership change at any level: not in the
3634
+ // directory itself, and not in the parent that now lists it
3635
+ // (samchon/ttsc#1307).
3636
+ const byPath = new Map(visited.map((visit) => [visit.path, visit]));
3637
+ const relevant = new Set<string>();
3638
+ for (const visit of visited) {
3639
+ if (!visit.ownInput) {
3640
+ continue;
3641
+ }
3642
+ let current: string | undefined = visit.path;
3643
+ while (current !== undefined && !relevant.has(current)) {
3644
+ relevant.add(current);
3645
+ const parent = path.dirname(current);
3646
+ current = parent === current || !byPath.has(parent) ? undefined : parent;
3647
+ }
2787
3648
  }
3649
+
3650
+ const directories: TtscProjectDirectorySnapshot[] = visited.map((visit) => {
3651
+ const membership = visit.entries
3652
+ .filter(
3653
+ (entry) =>
3654
+ entry.possible &&
3655
+ (!visit.childDirectories.includes(
3656
+ path.join(visit.path, entry.name),
3657
+ ) ||
3658
+ relevant.has(path.join(visit.path, entry.name))),
3659
+ )
3660
+ .map((entry) => `${entry.name}:${entry.kind}`);
3661
+ return {
3662
+ path: visit.path,
3663
+ relevant: relevant.has(visit.path),
3664
+ signature:
3665
+ visit.stable ??
3666
+ hashText(membership.sort().join(String.fromCharCode(0))),
3667
+ };
3668
+ });
2788
3669
  directories.sort((left, right) => left.path.localeCompare(right.path));
2789
3670
  files.sort();
2790
- return { complete, directories, files };
3671
+ return { complete, directories, failures, files };
2791
3672
  }
2792
3673
 
2793
- /** Return a cheap identity for one directory's immediate membership. */
3674
+ /**
3675
+ * Return a directory's metadata stamp, used to detect that its membership moved
3676
+ * _while_ the walk was enumerating it, and to feed the observed-clock floor.
3677
+ *
3678
+ * This is the right instrument for that job and the wrong one for comparing two
3679
+ * generations: it moves for ignored entries too. {@link walkProjectInputs}
3680
+ * records the filtered membership digest for the comparison instead.
3681
+ */
2794
3682
  function projectDirectorySignature(
2795
3683
  directory: string,
2796
3684
  filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
@@ -2821,14 +3709,31 @@ function sameProjectDirectories(
2821
3709
  left: readonly TtscProjectDirectorySnapshot[],
2822
3710
  right: readonly TtscProjectDirectorySnapshot[],
2823
3711
  ): 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
- );
3712
+ // Compare only the directories that can hold program inputs, on either side.
3713
+ // A directory irrelevant on both is not part of the program's membership at
3714
+ // all, so its appearance, disappearance or churn says nothing: that is a
3715
+ // bundler's output tree. One that gained or lost relevance is present in the
3716
+ // comparison from the side where it counts, and so is caught.
3717
+ const select = (
3718
+ snapshots: readonly TtscProjectDirectorySnapshot[],
3719
+ ): Map<string, TtscProjectDirectorySnapshot> =>
3720
+ new Map(
3721
+ snapshots
3722
+ .filter((directory) => directory.relevant)
3723
+ .map((directory) => [directory.path, directory]),
3724
+ );
3725
+ const leftRelevant = select(left);
3726
+ const rightRelevant = select(right);
3727
+ const paths = new Set([...leftRelevant.keys(), ...rightRelevant.keys()]);
3728
+ for (const location of paths) {
3729
+ if (
3730
+ leftRelevant.get(location)?.signature !==
3731
+ rightRelevant.get(location)?.signature
3732
+ ) {
3733
+ return false;
3734
+ }
3735
+ }
3736
+ return true;
2832
3737
  }
2833
3738
 
2834
3739
  /**
@@ -2860,8 +3765,11 @@ function openDirectoryWatch(
2860
3765
  async function createProjectMutationTracker(
2861
3766
  directories: readonly TtscProjectDirectorySnapshot[],
2862
3767
  filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
3768
+ policy: ITtscProjectMembershipPolicy = PERMISSIVE_PROJECT_MEMBERSHIP_POLICY,
2863
3769
  ): Promise<TtscProjectMutationTracker> {
2864
3770
  const tracker: TtscProjectMutationTracker = {
3771
+ changes: new Set(),
3772
+ changesOmitted: false,
2865
3773
  close: () => undefined,
2866
3774
  failed: false,
2867
3775
  membershipChanged: false,
@@ -2872,6 +3780,13 @@ async function createProjectMutationTracker(
2872
3780
  directories.map((directory) => ({ directory: directory.path })),
2873
3781
  false,
2874
3782
  filesystem,
3783
+ (location, filename) =>
3784
+ reportsProgramMembership(
3785
+ path.join(location, filename),
3786
+ filename,
3787
+ policy,
3788
+ filesystem,
3789
+ ),
2875
3790
  );
2876
3791
  return tracker;
2877
3792
  }
@@ -2886,8 +3801,27 @@ async function createProjectMutationTracker(
2886
3801
  openDirectoryWatch(
2887
3802
  filesystem,
2888
3803
  directory.path,
2889
- (eventType) => {
2890
- if (eventType === "rename") tracker.membershipChanged = true;
3804
+ (eventType, filename) => {
3805
+ if (eventType !== "rename") {
3806
+ return;
3807
+ }
3808
+ if (
3809
+ filename !== null &&
3810
+ !reportsProgramMembership(
3811
+ path.join(directory.path, filename),
3812
+ filename,
3813
+ policy,
3814
+ filesystem,
3815
+ )
3816
+ ) {
3817
+ return;
3818
+ }
3819
+ recordProjectMutation(
3820
+ tracker,
3821
+ filename === null
3822
+ ? directory.path
3823
+ : path.join(directory.path, filename),
3824
+ );
2891
3825
  },
2892
3826
  () => {
2893
3827
  tracker.failed = true;
@@ -2939,6 +3873,8 @@ async function createHostInputMutationTracker(
2939
3873
  names: [...location.names],
2940
3874
  }));
2941
3875
  const tracker: TtscProjectMutationTracker = {
3876
+ changes: new Set(),
3877
+ changesOmitted: false,
2942
3878
  close: () => undefined,
2943
3879
  // Coverage is the caller's claim, and it is required rather than derived
2944
3880
  // from the input list: an input is watched by its exact name here, but only
@@ -2981,7 +3917,12 @@ async function createHostInputMutationTracker(
2981
3917
  ? null
2982
3918
  : normalizeHostInputName(filename, caseSensitive);
2983
3919
  if (reported === null || names.has(reported)) {
2984
- tracker.membershipChanged = true;
3920
+ recordProjectMutation(
3921
+ tracker,
3922
+ filename === null
3923
+ ? location.directory
3924
+ : path.join(location.directory, filename),
3925
+ );
2985
3926
  }
2986
3927
  },
2987
3928
  () => {
@@ -2996,6 +3937,104 @@ async function createHostInputMutationTracker(
2996
3937
  return tracker;
2997
3938
  }
2998
3939
 
3940
+ /**
3941
+ * Whether a path lies inside a directory the configuration excludes.
3942
+ *
3943
+ * Lexical, exactly like the walk and like {@link isProjectWalkPath}, and for the
3944
+ * reason that predicate states: walk membership is lexical, so resolving a path
3945
+ * to physical identity first would collapse two spellings the walk keeps apart
3946
+ * and claim it covered a subtree it never followed. A junction whose target the
3947
+ * walk hashes under its own name is exactly that, and canonicalizing here would
3948
+ * suppress every event in it.
3949
+ *
3950
+ * `strictly` excludes an exact match, for the case where the excluded entry
3951
+ * names a file rather than a directory: `exclude` accepts one, the walk applies
3952
+ * exclusion to directories alone, so that file is still hashed and its events
3953
+ * must keep counting.
3954
+ */
3955
+ function insideExcludedProjectDirectory(
3956
+ location: string,
3957
+ policy: ITtscProjectMembershipPolicy,
3958
+ strictly: boolean,
3959
+ ): boolean {
3960
+ if (policy.excludedDirectories.length === 0) {
3961
+ return false;
3962
+ }
3963
+ const resolved = path.resolve(location);
3964
+ return policy.excludedDirectories.some((excluded) => {
3965
+ const target = path.resolve(excluded);
3966
+ if (strictly && target === resolved) {
3967
+ return false;
3968
+ }
3969
+ return pathIsWithin(resolved, target);
3970
+ });
3971
+ }
3972
+
3973
+ /**
3974
+ * Whether one directory event can be a change to the program's membership.
3975
+ *
3976
+ * The live tracker has to answer the same question the membership digest does,
3977
+ * or the two disagree about the same project: a bundler writing content-hashed
3978
+ * output fires a rename per rebuild, and treating that as membership kept the
3979
+ * cost samchon/ttsc#1307 removes on every host that has no build boundary,
3980
+ * which is every host the narrow path exists for.
3981
+ *
3982
+ * A name that could be a program input counts, unless it sits under a directory
3983
+ * the walk never descends into. A name that could not still counts when the
3984
+ * path is now a directory, because the walk's watches were opened for the
3985
+ * directories that existed when the generation was captured, so a directory
3986
+ * created since is not watched and the sources that may appear in it would
3987
+ * otherwise be invisible. A directory the configuration excludes is the
3988
+ * exception: the walk cannot see inside it, so the tracker must not either, or
3989
+ * emptying and recreating an `outDir` costs a compile per build. An event whose
3990
+ * name the host did not report is unattributable and always counts.
3991
+ */
3992
+ function reportsProgramMembership(
3993
+ location: string,
3994
+ filename: string,
3995
+ policy: ITtscProjectMembershipPolicy,
3996
+ filesystem: TtscTransformFilesystemOperations,
3997
+ ): boolean {
3998
+ if (isPossibleProgramFileName(filename, policy)) {
3999
+ // A name the program could admit. It still says nothing if it lies inside a
4000
+ // directory the walk never descends into, because the digest cannot see
4001
+ // there either and the tracker must not be the one side that reacts.
4002
+ return !insideExcludedProjectDirectory(location, policy, true);
4003
+ }
4004
+ let directory: boolean;
4005
+ try {
4006
+ directory = filesystem.lstat(location).isDirectory();
4007
+ } catch {
4008
+ // Gone again, or unreadable. Its name could not have been a program input,
4009
+ // and a directory removed under this one reports its own contents leaving
4010
+ // through the watch that was opened on it.
4011
+ return false;
4012
+ }
4013
+ if (!directory) {
4014
+ return false;
4015
+ }
4016
+ // A directory counts, because it can hold sources and the tracker is not
4017
+ // watching it yet, unless the configuration says the program does not contain
4018
+ // it. Emptying and recreating an `outDir`, which is what `emptyOutDir` and
4019
+ // `output.clean` do on every build, would otherwise void the generation once
4020
+ // per build on every host that has no build boundary.
4021
+ return !insideExcludedProjectDirectory(location, policy, false);
4022
+ }
4023
+
4024
+ /** Record enough exact mutation evidence without retaining an event stream. */
4025
+ function recordProjectMutation(
4026
+ tracker: TtscProjectMutationTracker,
4027
+ changed: string,
4028
+ ): void {
4029
+ tracker.membershipChanged = true;
4030
+ if (tracker.changes.has(changed)) return;
4031
+ if (tracker.changes.size < MAX_GENERATION_MUTATION_PATHS) {
4032
+ tracker.changes.add(changed);
4033
+ } else {
4034
+ tracker.changesOmitted = true;
4035
+ }
4036
+ }
4037
+
2999
4038
  interface WindowsProjectMutationBroker {
3000
4039
  child: ChildProcess;
3001
4040
  /** Round-trips awaiting the child's reply, by request id. */
@@ -3008,7 +4047,24 @@ interface WindowsProjectMutationBroker {
3008
4047
  trackers: Map<
3009
4048
  number,
3010
4049
  {
4050
+ /**
4051
+ * Whether one named event can be a program membership change. Present
4052
+ * only for the project-directory tracker, which watches whole directories
4053
+ * and so has to narrow what it hears; the trackers that watch exact names
4054
+ * have already narrowed theirs by construction.
4055
+ */
4056
+ membership?: (location: string, filename: string) => boolean;
3011
4057
  ready: () => void;
4058
+ /**
4059
+ * The walk's own spelling for each canonical directory the child watches,
4060
+ * so a reported event can be translated back before anything compares it
4061
+ * with a path the walk or the configuration produced.
4062
+ *
4063
+ * Required, not optional. A registration that forgot it would fall back
4064
+ * to the child's canonical spelling and silently reintroduce the mismatch
4065
+ * this map exists to remove, with no type error and no failing test.
4066
+ */
4067
+ spellings: ReadonlyMap<string, string>;
3012
4068
  tracker: TtscProjectMutationTracker;
3013
4069
  }
3014
4070
  >;
@@ -3033,8 +4089,21 @@ async function registerWindowsProjectMutationTracker(
3033
4089
  locations: readonly WindowsMutationLocation[],
3034
4090
  allEvents: boolean,
3035
4091
  filesystem: TtscTransformFilesystemOperations,
4092
+ /**
4093
+ * Optional filter for the project-directory tracker, whose events have to be
4094
+ * narrowed to program membership exactly as the in-process watcher's are. The
4095
+ * name-watching trackers pass none, since they already watch exact names.
4096
+ */
4097
+ membership?: (location: string, filename: string) => boolean,
3036
4098
  ): Promise<void> {
3037
4099
  const broker = getWindowsProjectMutationBroker();
4100
+ // The child watches canonical directories, and reports its events under that
4101
+ // spelling. Everything else in the adapter speaks the walk's own spelling,
4102
+ // which on Windows can be an 8.3 short form of the same directory, so keep
4103
+ // the way back: a filter that compared the child's spelling against the
4104
+ // configuration's would be comparing two names for one directory that share
4105
+ // no common prefix (samchon/ttsc#1307).
4106
+ const spellings = new Map<string, string>();
3038
4107
  const normalized = locations.map((location) => {
3039
4108
  let directory: string;
3040
4109
  try {
@@ -3042,6 +4111,7 @@ async function registerWindowsProjectMutationTracker(
3042
4111
  } catch {
3043
4112
  directory = path.resolve(location.directory);
3044
4113
  }
4114
+ spellings.set(directory, location.directory);
3045
4115
  return {
3046
4116
  directory,
3047
4117
  ...(location.names === undefined ? {} : { names: location.names }),
@@ -3055,7 +4125,12 @@ async function registerWindowsProjectMutationTracker(
3055
4125
  const ready = new Promise<void>((resolve) => {
3056
4126
  resolveReady = resolve;
3057
4127
  });
3058
- broker.trackers.set(id, { ready: resolveReady, tracker });
4128
+ broker.trackers.set(id, {
4129
+ membership,
4130
+ ready: resolveReady,
4131
+ spellings,
4132
+ tracker,
4133
+ });
3059
4134
  tracker.drain = () => drainWindowsProjectMutationBroker(broker);
3060
4135
  tracker.close = () => {
3061
4136
  const active = broker.trackers.get(id);
@@ -3128,8 +4203,10 @@ function getWindowsProjectMutationBroker(): WindowsProjectMutationBroker {
3128
4203
  child.on("message", (message: unknown) => {
3129
4204
  if (message === null || typeof message !== "object") return;
3130
4205
  const record = message as {
4206
+ directory?: string;
3131
4207
  drained?: boolean;
3132
4208
  failed?: boolean;
4209
+ filename?: string | null;
3133
4210
  id?: number;
3134
4211
  ready?: boolean;
3135
4212
  };
@@ -3147,7 +4224,27 @@ function getWindowsProjectMutationBroker(): WindowsProjectMutationBroker {
3147
4224
  if (record.failed === true) registration.tracker.failed = true;
3148
4225
  if (record.ready === true) registration.ready();
3149
4226
  if (record.ready !== true && record.failed !== true) {
3150
- registration.tracker.membershipChanged = true;
4227
+ if (typeof record.directory === "string") {
4228
+ // The walk's spelling for this directory, which is what every
4229
+ // comparison and every recorded witness downstream expects.
4230
+ const reported =
4231
+ registration.spellings.get(record.directory) ?? record.directory;
4232
+ if (
4233
+ typeof record.filename === "string" &&
4234
+ registration.membership !== undefined &&
4235
+ !registration.membership(reported, record.filename)
4236
+ ) {
4237
+ return;
4238
+ }
4239
+ recordProjectMutation(
4240
+ registration.tracker,
4241
+ typeof record.filename === "string"
4242
+ ? path.join(reported, record.filename)
4243
+ : reported,
4244
+ );
4245
+ } else {
4246
+ registration.tracker.membershipChanged = true;
4247
+ }
3151
4248
  }
3152
4249
  });
3153
4250
  windowsProjectMutationBroker = broker;
@@ -3238,7 +4335,7 @@ const WINDOWS_WATCH_BROKER_SOURCE = [
3238
4335
  " const names = location.names === undefined ? undefined : new Set(location.names.map((name) => name.toLowerCase()));",
3239
4336
  " const watcher = fs.watch(location.directory, { persistent: false }, (event, filename) => {",
3240
4337
  " const matches = names === undefined || filename === null || names.has(String(filename).toLowerCase());",
3241
- ' if (matches && (message.allEvents || event === "rename")) process.send?.({ id: message.id });',
4338
+ ' if (matches && (message.allEvents || event === "rename")) process.send?.({ directory: location.directory, filename: filename === null ? null : String(filename), id: message.id });',
3242
4339
  " });",
3243
4340
  ' watcher.on("error", () => process.send?.({ failed: true, id: message.id }));',
3244
4341
  " watchers.push(watcher);",
@@ -3351,6 +4448,7 @@ export function isProjectWalkPath(
3351
4448
  file: string,
3352
4449
  _identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
3353
4450
  filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
4451
+ policy: ITtscProjectMembershipPolicy = PERMISSIVE_PROJECT_MEMBERSHIP_POLICY,
3354
4452
  ): boolean {
3355
4453
  // Walk membership is lexical. Resolving `file` to physical identity first
3356
4454
  // would turn `root/alias/value.ts` into `root/target/value.ts`, hide the
@@ -3367,7 +4465,20 @@ export function isProjectWalkPath(
3367
4465
  return false;
3368
4466
  }
3369
4467
  const segments = relative.split(path.sep);
3370
- if (segments.some(isIgnoredProjectDirectory)) {
4468
+ // The last segment is the file itself, which the walk names rather than
4469
+ // descends into, so only the directory components decide walk membership.
4470
+ if (segments.slice(0, -1).some(isIgnoredProjectDirectory)) {
4471
+ return false;
4472
+ }
4473
+ if (isExcludedProjectDirectory(path.dirname(path.resolve(file)), policy)) {
4474
+ return false;
4475
+ }
4476
+ // The walk hashes only files that could enter the program, so a path it does
4477
+ // not hash is out of the walk by definition. Answering otherwise would leave
4478
+ // a graph input the compiler really read in neither snapshot: absent from
4479
+ // `inputHashes` because the walk skipped it, and absent from the out-of-walk
4480
+ // snapshot because this predicate claimed the walk covered it.
4481
+ if (!isPossibleProgramFileName(path.basename(file), policy)) {
3371
4482
  return false;
3372
4483
  }
3373
4484
  let current = resolvedRoot;
@@ -3483,12 +4594,13 @@ function matchesCachedExternalInputs(cached: TtscCachedProjectTransform): {
3483
4594
 
3484
4595
  /**
3485
4596
  * Derive the absolute out-of-walk input set of a whole project transform: the
3486
- * union of every reference-graph member (edge keys and targets, globals, the
3487
- * config chain) and every plugin-reported dependency, minus everything the
3488
- * project walk already hashes and the disposed temp-dir tsconfig. These are the
3489
- * inputs {@link matchesCachedSource}'s walk cannot see. Resolution candidates
3490
- * that are still missing remain in this set even under the project root: the
3491
- * first walk cannot hash a file that has not been created yet.
4597
+ * union of every transformed source key, reference-graph member (edge keys and
4598
+ * targets, globals, the config chain), and plugin-reported dependency, minus
4599
+ * everything the project walk already hashes and the disposed transform scratch
4600
+ * tree. These are the inputs {@link matchesCachedSource}'s walk cannot see.
4601
+ * Resolution candidates that are still missing remain in this set even under
4602
+ * the project root: the first walk cannot hash a file that has not been created
4603
+ * yet.
3492
4604
  *
3493
4605
  * A `dependenciesComplete` declaration deliberately does not narrow the stored
3494
4606
  * set: other files in the same whole-project result can still own the omitted
@@ -3498,8 +4610,10 @@ function matchesCachedExternalInputs(cached: TtscCachedProjectTransform): {
3498
4610
  */
3499
4611
  function selectExternalInputPaths(props: {
3500
4612
  filesystem?: TtscTransformFilesystemOperations;
4613
+ membershipPolicy: ITtscProjectMembershipPolicy;
3501
4614
  projectRoot: string;
3502
4615
  result: ITtscCompilerTransformation;
4616
+ scratchDirectory?: string;
3503
4617
  temporaryTsconfig?: string;
3504
4618
  }): string[] {
3505
4619
  if (props.result.type === "exception") {
@@ -3510,6 +4624,10 @@ function selectExternalInputPaths(props: {
3510
4624
  const identities = createHostPathIdentityContext(filesystem);
3511
4625
  const resolutionCandidates = new Set<string>();
3512
4626
  const graph = props.result.graph;
4627
+ // Every transform output key names the source file whose transformed text it
4628
+ // carries. Keep an out-of-walk source in the external snapshot instead of
4629
+ // injecting it into the project-walk key universe (samchon/ttsc#252).
4630
+ members.push(...Object.keys(props.result.typescript));
3513
4631
  if (graph !== undefined) {
3514
4632
  for (const [source, targets] of Object.entries(graph.edges ?? {})) {
3515
4633
  members.push(source);
@@ -3571,9 +4689,16 @@ function selectExternalInputPaths(props: {
3571
4689
  resolutionCandidates.has(identity) && !filesystem.exists(absolute);
3572
4690
  if (
3573
4691
  identity === excluded ||
4692
+ isTransformScratchInput(absolute, props.scratchDirectory) ||
3574
4693
  seen.has(spelling) ||
3575
4694
  (!missingCandidate &&
3576
- isProjectWalkPath(props.projectRoot, absolute, identities, filesystem))
4695
+ isProjectWalkPath(
4696
+ props.projectRoot,
4697
+ absolute,
4698
+ identities,
4699
+ filesystem,
4700
+ props.membershipPolicy,
4701
+ ))
3577
4702
  ) {
3578
4703
  continue;
3579
4704
  }
@@ -3607,6 +4732,7 @@ function selectNotifiableAbsentInputs(props: {
3607
4732
  filesystem: TtscTransformFilesystemOperations;
3608
4733
  projectRoot: string;
3609
4734
  result: ITtscCompilerTransformation;
4735
+ scratchDirectory?: string;
3610
4736
  temporaryTsconfig?: string;
3611
4737
  }): { candidates: string[]; watched: string[] } {
3612
4738
  const empty = { candidates: [], watched: [] };
@@ -3643,6 +4769,7 @@ function selectNotifiableAbsentInputs(props: {
3643
4769
  const spelling = path.resolve(absolute);
3644
4770
  if (
3645
4771
  seen.has(spelling) ||
4772
+ isTransformScratchInput(absolute, props.scratchDirectory) ||
3646
4773
  (excluded !== undefined &&
3647
4774
  pathIdentityKey(absolute, identities) === excluded) ||
3648
4775
  props.filesystem.exists(absolute)
@@ -3756,22 +4883,57 @@ function insideProject(directory: string, projectRoot: string): boolean {
3756
4883
  const NOTIFIABLE_ABSENCE_DIRECTORY_LIMIT = 512;
3757
4884
 
3758
4885
  function isIgnoredProjectDirectory(name: string): boolean {
3759
- return (
3760
- name === ".git" ||
3761
- name === ".ttsc" ||
3762
- name === ".cache" ||
3763
- name === ".next" ||
3764
- name === ".nuxt" ||
3765
- name === ".svelte-kit" ||
3766
- name === ".turbo" ||
3767
- name === ".vite" ||
3768
- name === "build" ||
3769
- name === "coverage" ||
3770
- name === "dist" ||
3771
- name === "node_modules" ||
3772
- name === "out" ||
3773
- name === "temp" ||
3774
- name === "tmp"
4886
+ // The residue of what used to be a fifteen-name list, kept to the three
4887
+ // directories no tsconfig can name and no program can contain: the VCS
4888
+ // store, the package manager's tree (TypeScript's own default `exclude`
4889
+ // carries it too), and ttsc's own plugin cache. Everything else the old list
4890
+ // guessed at, and guessing was wrong in both directions: a bundler writing
4891
+ // to an unnamed directory changed project membership with its own output,
4892
+ // while a real source directory named `build` or `temp` was dropped from the
4893
+ // walk and its new files were never seen (samchon/ttsc#1307). Those are now
4894
+ // decided by `ITtscProjectMembershipPolicy`, which reads the configuration
4895
+ // that actually knows.
4896
+ return name === ".git" || name === ".ttsc" || name === "node_modules";
4897
+ }
4898
+
4899
+ /**
4900
+ * Whether the resolved configuration keeps this directory out of the program.
4901
+ *
4902
+ * Compared by physical containment rather than by name, so `outDir: "./dist"`
4903
+ * excludes that one directory instead of every directory called `dist` at every
4904
+ * depth, which is the distinction the name list could not draw.
4905
+ */
4906
+ function isExcludedProjectDirectory(
4907
+ directory: string,
4908
+ policy: ITtscProjectMembershipPolicy,
4909
+ ): boolean {
4910
+ return insideExcludedProjectDirectory(directory, policy, false);
4911
+ }
4912
+
4913
+ /**
4914
+ * Whether this entry could enter the program, and so whether its appearance or
4915
+ * removal is a membership change.
4916
+ *
4917
+ * A directory always could, since it can hold sources. A file could only if it
4918
+ * carries an extension the resolved configuration admits, which is what makes a
4919
+ * bundle emitted beside the sources invisible to a project that compiles no
4920
+ * JavaScript.
4921
+ */
4922
+ function isPossibleProgramEntry(
4923
+ entry: fs.Dirent,
4924
+ policy: ITtscProjectMembershipPolicy,
4925
+ ): boolean {
4926
+ return entry.isFile() ? isPossibleProgramFileName(entry.name, policy) : true;
4927
+ }
4928
+
4929
+ /** The same question for a bare file name, for callers holding no `Dirent`. */
4930
+ function isPossibleProgramFileName(
4931
+ name: string,
4932
+ policy: ITtscProjectMembershipPolicy,
4933
+ ): boolean {
4934
+ const lowered = name.toLowerCase();
4935
+ return policy.inputExtensions.some((extension) =>
4936
+ lowered.endsWith(extension),
3775
4937
  );
3776
4938
  }
3777
4939
 
@@ -3825,7 +4987,7 @@ function walkSnapshotComplete(
3825
4987
  snapshot: {
3826
4988
  complete: boolean;
3827
4989
  directoryComplete: boolean;
3828
- unstableFiles: Set<string>;
4990
+ unstableFiles: ReadonlySet<string>;
3829
4991
  },
3830
4992
  declared: ReadonlySet<string> | undefined,
3831
4993
  ): boolean {
@@ -3841,6 +5003,136 @@ function walkSnapshotComplete(
3841
5003
  return true;
3842
5004
  }
3843
5005
 
5006
+ /** Preserve exact project-walk and mutation witnesses for one failed attempt. */
5007
+ function recordProjectSnapshotFailures(
5008
+ failures: TtscGenerationProofFailures,
5009
+ props: {
5010
+ before: ReturnType<typeof collectProjectInputSnapshot>;
5011
+ candidateTracker?: TtscProjectMutationTracker;
5012
+ declared: ReadonlySet<string> | undefined;
5013
+ hostInputTracker?: TtscProjectMutationTracker;
5014
+ identities: FilesystemPathIdentityContext;
5015
+ projectRoot: string;
5016
+ snapshot: ReturnType<typeof collectProjectInputSnapshot>;
5017
+ tracker?: TtscProjectMutationTracker;
5018
+ },
5019
+ ): void {
5020
+ const recordWalk = (
5021
+ snapshot: ReturnType<typeof collectProjectInputSnapshot>,
5022
+ ): void => {
5023
+ for (const failure of snapshot.walkFailures) {
5024
+ if (failure.kind.startsWith("file-") && props.declared !== undefined) {
5025
+ try {
5026
+ const key = toProjectKey(
5027
+ props.projectRoot,
5028
+ failure.path,
5029
+ props.identities,
5030
+ );
5031
+ if (!props.declared.has(key)) continue;
5032
+ } catch {
5033
+ // An unidentifiable failed input taints the complete project walk.
5034
+ }
5035
+ }
5036
+ recordGenerationProofFailure(failures, {
5037
+ domain: "project",
5038
+ kind: failure.kind,
5039
+ path: failure.path,
5040
+ });
5041
+ }
5042
+ };
5043
+ recordWalk(props.before);
5044
+ recordWalk(props.snapshot);
5045
+
5046
+ const keys =
5047
+ props.declared ??
5048
+ new Set([
5049
+ ...Object.keys(props.before.hashes),
5050
+ ...Object.keys(props.snapshot.hashes),
5051
+ ]);
5052
+ for (const key of keys) {
5053
+ if (props.before.hashes[key] !== props.snapshot.hashes[key]) {
5054
+ recordGenerationProofFailure(failures, {
5055
+ domain: "project",
5056
+ kind: "input-content-changed",
5057
+ path: path.resolve(props.projectRoot, key),
5058
+ });
5059
+ }
5060
+ if (
5061
+ props.before.fileSignatures[key] !== props.snapshot.fileSignatures[key]
5062
+ ) {
5063
+ recordGenerationProofFailure(failures, {
5064
+ domain: "project",
5065
+ kind: "input-metadata-changed",
5066
+ path: path.resolve(props.projectRoot, key),
5067
+ });
5068
+ }
5069
+ }
5070
+
5071
+ const leftDirectories = new Map(
5072
+ props.before.projectDirectories.map((entry) => [
5073
+ entry.path,
5074
+ entry.signature,
5075
+ ]),
5076
+ );
5077
+ const rightDirectories = new Map(
5078
+ props.snapshot.projectDirectories.map((entry) => [
5079
+ entry.path,
5080
+ entry.signature,
5081
+ ]),
5082
+ );
5083
+ for (const directory of new Set([
5084
+ ...leftDirectories.keys(),
5085
+ ...rightDirectories.keys(),
5086
+ ])) {
5087
+ if (leftDirectories.get(directory) !== rightDirectories.get(directory)) {
5088
+ recordGenerationProofFailure(failures, {
5089
+ domain: "project",
5090
+ kind: "directory-membership-changed",
5091
+ path: directory,
5092
+ });
5093
+ }
5094
+ }
5095
+
5096
+ const recordTracker = (
5097
+ tracker: TtscProjectMutationTracker | undefined,
5098
+ kind: string,
5099
+ ): void => {
5100
+ if (tracker?.membershipChanged !== true) return;
5101
+ if (tracker.changes.size === 0) {
5102
+ recordGenerationProofFailure(failures, {
5103
+ domain: "project",
5104
+ kind,
5105
+ path: props.projectRoot,
5106
+ });
5107
+ return;
5108
+ }
5109
+ for (const changed of tracker.changes) {
5110
+ recordGenerationProofFailure(failures, {
5111
+ domain: "project",
5112
+ kind,
5113
+ path: changed,
5114
+ });
5115
+ }
5116
+ if (tracker.changesOmitted) {
5117
+ failures.omitted = Math.min(
5118
+ Number.MAX_SAFE_INTEGER,
5119
+ failures.omitted + 1,
5120
+ );
5121
+ }
5122
+ };
5123
+ recordTracker(props.tracker, "project-membership-event");
5124
+ recordTracker(props.hostInputTracker, "host-input-event");
5125
+ recordTracker(props.candidateTracker, "candidate-event");
5126
+
5127
+ if (failures.entries.length === 0) {
5128
+ recordGenerationProofFailure(failures, {
5129
+ domain: "project",
5130
+ kind: "snapshot-incomplete",
5131
+ path: props.projectRoot,
5132
+ });
5133
+ }
5134
+ }
5135
+
3844
5136
  /** {@link selectDeclaredProjectInputKeys} memoized per envelope generation. */
3845
5137
  function declaredProjectInputKeys(
3846
5138
  state: TtscEnvelopeDerivation,
@@ -3851,6 +5143,7 @@ function declaredProjectInputKeys(
3851
5143
  identities: state.identityContext,
3852
5144
  projectRoot: cached.projectRoot,
3853
5145
  result: cached.result,
5146
+ scratchDirectory: cached.scratchDirectory,
3854
5147
  });
3855
5148
  state.declaredInputKeysBuilt = true;
3856
5149
  }
@@ -3867,6 +5160,7 @@ function selectDeclaredProjectInputKeys(props: {
3867
5160
  identities: FilesystemPathIdentityContext;
3868
5161
  projectRoot: string;
3869
5162
  result: ITtscCompilerTransformation;
5163
+ scratchDirectory?: string;
3870
5164
  }): Set<string> | undefined {
3871
5165
  if (props.result.type === "exception" || props.result.graph === undefined) {
3872
5166
  return undefined;
@@ -3875,13 +5169,9 @@ function selectDeclaredProjectInputKeys(props: {
3875
5169
  const keys = new Set<string>();
3876
5170
  const add = (entry: unknown): void => {
3877
5171
  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
- );
5172
+ const absolute = path.resolve(props.projectRoot, entry);
5173
+ if (isTransformScratchInput(absolute, props.scratchDirectory)) return;
5174
+ keys.add(toProjectKey(props.projectRoot, absolute, props.identities));
3885
5175
  };
3886
5176
  for (const [source, targets] of Object.entries(graph.edges ?? {})) {
3887
5177
  add(source);
@@ -3905,52 +5195,290 @@ function selectDeclaredProjectInputKeys(props: {
3905
5195
  return keys;
3906
5196
  }
3907
5197
 
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>();
5198
+ /** Create an empty bounded witness collection for one transform attempt. */
5199
+ function createGenerationProofFailures(): TtscGenerationProofFailures {
5200
+ return { entries: [], omitted: 0, seen: new Set() };
5201
+ }
5202
+
5203
+ /** Retain one unique proof witness without allowing diagnostics to grow freely. */
5204
+ function recordGenerationProofFailure(
5205
+ failures: TtscGenerationProofFailures,
5206
+ failure: TtscGenerationProofFailure,
5207
+ ): void {
5208
+ const key = JSON.stringify([
5209
+ failure.domain,
5210
+ failure.kind,
5211
+ failure.path,
5212
+ failure.detail,
5213
+ ]);
5214
+ if (failures.seen.has(key)) return;
5215
+ if (failures.entries.length < MAX_GENERATION_PROOF_FAILURES) {
5216
+ // `seen` follows the same bound as `entries`: retaining every discarded
5217
+ // identity would make a bounded diagnostic an unbounded memory sink.
5218
+ failures.seen.add(key);
5219
+ failures.entries.push(failure);
5220
+ } else {
5221
+ failures.omitted = Math.min(Number.MAX_SAFE_INTEGER, failures.omitted + 1);
5222
+ }
5223
+ }
5224
+
5225
+ /** Fold one bounded witness collection into another. */
5226
+ function mergeGenerationProofFailures(
5227
+ target: TtscGenerationProofFailures,
5228
+ source: TtscGenerationProofFailures,
5229
+ ): void {
5230
+ for (const failure of source.entries) {
5231
+ recordGenerationProofFailure(target, failure);
5232
+ }
5233
+ target.omitted = Math.min(
5234
+ Number.MAX_SAFE_INTEGER,
5235
+ target.omitted + source.omitted,
5236
+ );
5237
+ }
5238
+
5239
+ /** Hash the declared-input-relevant failure shape without retaining it. */
5240
+ function projectWalkFailureFingerprint(
5241
+ snapshot: {
5242
+ complete: boolean;
5243
+ directoryComplete: boolean;
5244
+ unstableFiles: ReadonlySet<string>;
5245
+ walkFailures: readonly TtscProjectWalkFailure[];
5246
+ },
5247
+ declared: ReadonlySet<string> | undefined,
5248
+ projectRoot: string,
5249
+ identities: FilesystemPathIdentityContext,
5250
+ ): string {
5251
+ const relevantUnstableFiles =
5252
+ declared === undefined
5253
+ ? [...snapshot.unstableFiles]
5254
+ : [...snapshot.unstableFiles].filter((key) => declared.has(key));
5255
+ const relevantFailures = snapshot.walkFailures.filter((failure) => {
5256
+ if (!failure.kind.startsWith("file-")) return true;
5257
+ if (declared === undefined) return true;
5258
+ try {
5259
+ return declared.has(toProjectKey(projectRoot, failure.path, identities));
5260
+ } catch {
5261
+ return true;
5262
+ }
5263
+ });
5264
+ return hashText(
5265
+ JSON.stringify({
5266
+ complete: walkSnapshotComplete(snapshot, declared),
5267
+ directoryComplete: snapshot.directoryComplete,
5268
+ failures: relevantFailures
5269
+ .map((failure) => `${failure.kind}\0${path.resolve(failure.path)}`)
5270
+ .sort(),
5271
+ unstableFiles: relevantUnstableFiles.sort(),
5272
+ }),
5273
+ );
5274
+ }
5275
+
5276
+ /** Compact state of one exact out-of-walk input in a failed generation. */
5277
+ function failedGenerationInputState(
5278
+ input: string,
5279
+ filesystem: TtscTransformFilesystemOperations,
5280
+ ): string {
5281
+ let directory = "not-directory";
5282
+ try {
5283
+ if (filesystem.stat(input).isDirectory()) {
5284
+ directory = hashText(
5285
+ filesystem
5286
+ .readdir(input)
5287
+ .map((entry) =>
5288
+ [
5289
+ entry.name,
5290
+ entry.isDirectory(),
5291
+ entry.isFile(),
5292
+ entry.isSymbolicLink(),
5293
+ ].join(":"),
5294
+ )
5295
+ .sort()
5296
+ .join("\0"),
5297
+ );
5298
+ }
5299
+ } catch {
5300
+ directory = "unavailable";
5301
+ }
5302
+ return hashText(
5303
+ JSON.stringify([
5304
+ inputMetadataSignature(input, filesystem) ?? "missing",
5305
+ hostInputStateHash(input, filesystem) ?? MISSING_INPUT_STATE,
5306
+ hostInputRealpath(input, filesystem),
5307
+ directory,
5308
+ ]),
5309
+ );
5310
+ }
5311
+
5312
+ /** Snapshot every input outside the project walk that could change a retry. */
5313
+ function captureFailedGenerationInputStates(
5314
+ cached: TtscCachedProjectTransform,
5315
+ failures: TtscGenerationProofFailures,
5316
+ ): ReadonlyMap<string, string> {
5317
+ const filesystem = resultFilesystem(cached.result);
5318
+ const inputs = new Set(
5319
+ (cached.externalInputPaths ?? []).map((input) => path.resolve(input)),
5320
+ );
5321
+ for (const input of selectPersistentHostInputs({
5322
+ filesystem,
5323
+ projectRoot: cached.projectRoot,
5324
+ result: cached.result,
5325
+ scratchDirectory: cached.scratchDirectory,
5326
+ temporaryTsconfig: cached.temporaryTsconfig,
5327
+ })) {
5328
+ inputs.add(path.resolve(input));
5329
+ }
5330
+ for (const failure of failures.entries) {
5331
+ if (failure.path !== undefined) inputs.add(path.resolve(failure.path));
5332
+ }
5333
+ return new Map(
5334
+ [...inputs]
5335
+ .sort()
5336
+ .map((input) => [input, failedGenerationInputState(input, filesystem)]),
5337
+ );
5338
+ }
5339
+
5340
+ /** Capture source baselines for project and out-of-walk transform outputs. */
5341
+ function captureTransformSourceHashes(
5342
+ cached: TtscCachedProjectTransform,
5343
+ currentFile: string,
5344
+ currentSourceHash: string,
5345
+ ): Record<string, string> {
5346
+ const filesystem = resultFilesystem(cached.result);
5347
+ const identities = envelopeDerivation(cached).identityContext;
5348
+ const hashes: Record<string, string> = {};
5349
+ if (cached.result.type === "success") {
5350
+ for (const output of Object.keys(cached.result.typescript)) {
5351
+ const file = path.resolve(cached.projectRoot, output);
5352
+ const hash = hostInputStateHash(file, filesystem);
5353
+ if (hash !== null) hashes[pathIdentityKey(file, identities)] = hash;
5354
+ }
5355
+ }
5356
+ hashes[pathIdentityKey(currentFile, identities)] = currentSourceHash;
5357
+ return hashes;
5358
+ }
3913
5359
 
3914
5360
  /**
3915
- * Report, once per project root, that a generation cannot be reused.
5361
+ * Whether a terminal proof failure's observed environment actually changed.
3916
5362
  *
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.
5363
+ * This is deliberately a confirmation test: inability to re-probe retains the
5364
+ * old verdict instead of turning every module request into another compile.
5365
+ * Cache lifecycle reset remains the unconditional recovery boundary.
3923
5366
  */
3924
- function reportUnreusableGeneration(
3925
- cached: TtscCachedProjectTransform,
3926
- evidence: {
3927
- externalInputs: boolean;
3928
- graphProofs: boolean;
3929
- universalInputs: boolean;
3930
- walkStable: boolean;
5367
+ function failedGenerationEnvironmentChanged(
5368
+ validation: TtscFailedGenerationValidation,
5369
+ props: {
5370
+ currentFile: string;
5371
+ currentSource: string;
5372
+ filesystem: TtscTransformFilesystemOperations;
3931
5373
  },
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;
5374
+ ): boolean {
5375
+ try {
5376
+ const identities = envelopeDerivation(validation.cached).identityContext;
5377
+ const currentSourceHash = hashText(props.currentSource);
5378
+ const expectedSourceHash =
5379
+ validation.cached.sourceHashes?.[
5380
+ pathIdentityKey(props.currentFile, identities)
5381
+ ];
5382
+ if (
5383
+ expectedSourceHash !== undefined &&
5384
+ expectedSourceHash !== currentSourceHash
5385
+ ) {
5386
+ return true;
5387
+ }
5388
+ const current = collectProjectInputSnapshot(
5389
+ validation.cached.projectRoot,
5390
+ identities,
5391
+ props.filesystem,
5392
+ undefined,
5393
+ { policy: validation.cached.membershipPolicy },
5394
+ );
5395
+ if (
5396
+ validation.projectWalkComplete !==
5397
+ walkSnapshotComplete(current, validation.declaredInputs) ||
5398
+ validation.projectWalkFailures !==
5399
+ projectWalkFailureFingerprint(
5400
+ current,
5401
+ validation.declaredInputs,
5402
+ validation.cached.projectRoot,
5403
+ identities,
5404
+ ) ||
5405
+ !sameHashes(
5406
+ validation.projectInputHashes,
5407
+ current.hashes,
5408
+ validation.declaredInputs,
5409
+ ) ||
5410
+ !sameProjectDirectories(
5411
+ validation.cached.projectDirectories ?? [],
5412
+ current.projectDirectories,
5413
+ )
5414
+ ) {
5415
+ return true;
5416
+ }
5417
+ for (const [input, recorded] of validation.inputStates) {
5418
+ if (failedGenerationInputState(input, props.filesystem) !== recorded) {
5419
+ return true;
5420
+ }
5421
+ }
5422
+ return false;
5423
+ } catch {
5424
+ return false;
3944
5425
  }
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`,
5426
+ }
5427
+
5428
+ /** Render one input without leaking source content or control characters. */
5429
+ function formatGenerationFailurePath(
5430
+ projectRoot: string,
5431
+ input: string,
5432
+ ): string {
5433
+ const absolute = path.resolve(input);
5434
+ const relative = path.relative(projectRoot, absolute);
5435
+ const display =
5436
+ relative === ""
5437
+ ? "."
5438
+ : relative !== ".." &&
5439
+ !relative.startsWith(`..${path.sep}`) &&
5440
+ !path.isAbsolute(relative)
5441
+ ? relative
5442
+ : absolute;
5443
+ return JSON.stringify(display.split(path.sep).join("/"));
5444
+ }
5445
+
5446
+ /** Build the terminal error shared by every waiter of an unstable generation. */
5447
+ function createUnstableGenerationError(
5448
+ projectRoot: string,
5449
+ attempts: readonly TtscGenerationProofFailures[],
5450
+ validation: TtscFailedGenerationValidation,
5451
+ ): TtscUnstableGenerationError {
5452
+ const lines = [
5453
+ `ttsc: could not capture a reusable transform generation after ${attempts.length} attempts.`,
5454
+ ` project: ${projectRoot}`,
5455
+ ];
5456
+ attempts.forEach((failures, index) => {
5457
+ lines.push(` attempt ${index + 1}:`);
5458
+ if (failures.entries.length === 0) {
5459
+ lines.push(" - project/generation-proof-incomplete");
5460
+ }
5461
+ for (const failure of failures.entries) {
5462
+ const input =
5463
+ failure.path === undefined
5464
+ ? ""
5465
+ : `: ${formatGenerationFailurePath(projectRoot, failure.path)}`;
5466
+ const detail =
5467
+ failure.detail === undefined
5468
+ ? ""
5469
+ : ` (producer: ${JSON.stringify(failure.detail)})`;
5470
+ lines.push(` - ${failure.domain}/${failure.kind}${input}${detail}`);
5471
+ }
5472
+ if (failures.omitted !== 0) {
5473
+ lines.push(
5474
+ ` - ... ${failures.omitted} additional witness(es) omitted`,
5475
+ );
5476
+ }
5477
+ });
5478
+ lines.push(
5479
+ " Stop writes to the listed inputs before compilation, or fix the producer that omitted or contradicted the listed proof.",
3953
5480
  );
5481
+ return new TtscUnstableGenerationError(lines.join("\n"), validation);
3954
5482
  }
3955
5483
 
3956
5484
  function hashText(input: string | Buffer): string {
@@ -3962,6 +5490,58 @@ async function transformProject(props: {
3962
5490
  compilerOptions: Record<string, unknown>;
3963
5491
  currentFile: string;
3964
5492
  currentSource: string;
5493
+ /**
5494
+ * Delivery pass this compile was started for; see
5495
+ * {@link TtscCachedProjectTransform.deliveryEpoch}.
5496
+ */
5497
+ deliveryEpoch?: number;
5498
+ filesystem: TtscTransformFilesystemOperations;
5499
+ plugins?: ResolvedTtscUnpluginOptions["plugins"];
5500
+ trackProjectMembership: boolean;
5501
+ tsconfig: string;
5502
+ }): Promise<TtscCachedProjectTransform> {
5503
+ const attempts: TtscGenerationProofFailures[] = [];
5504
+ for (let attempt = 0; attempt < TRANSFORM_GENERATION_ATTEMPTS; attempt += 1) {
5505
+ const cached = await captureTransformGeneration(props);
5506
+ if (
5507
+ !props.trackProjectMembership ||
5508
+ cached.result.type !== "success" ||
5509
+ cached.projectSnapshotComplete === true
5510
+ ) {
5511
+ return cached;
5512
+ }
5513
+ attempts.push(
5514
+ TRANSFORM_GENERATION_FAILURES.get(cached.result) ??
5515
+ createGenerationProofFailures(),
5516
+ );
5517
+ if (attempt + 1 === TRANSFORM_GENERATION_ATTEMPTS) {
5518
+ const validation = TRANSFORM_FAILED_GENERATION_VALIDATIONS.get(
5519
+ cached.result,
5520
+ );
5521
+ if (validation === undefined) {
5522
+ disposeCachedTransform(cached);
5523
+ throw new Error(
5524
+ "ttsc: failed transform generation has no retry validation baseline",
5525
+ );
5526
+ }
5527
+ throw createUnstableGenerationError(
5528
+ path.dirname(props.tsconfig),
5529
+ attempts,
5530
+ validation,
5531
+ );
5532
+ }
5533
+ disposeCachedTransform(cached);
5534
+ }
5535
+ throw new Error("ttsc: transform generation retry loop did not terminate");
5536
+ }
5537
+
5538
+ /** Capture one whole-project transform attempt and all of its reuse proofs. */
5539
+ async function captureTransformGeneration(props: {
5540
+ aliasPaths: Record<string, string[]>;
5541
+ compilerOptions: Record<string, unknown>;
5542
+ currentFile: string;
5543
+ currentSource: string;
5544
+ deliveryEpoch?: number;
3965
5545
  filesystem: TtscTransformFilesystemOperations;
3966
5546
  plugins?: ResolvedTtscUnpluginOptions["plugins"];
3967
5547
  trackProjectMembership: boolean;
@@ -3983,15 +5563,27 @@ async function transformProject(props: {
3983
5563
  const temporaryTsconfig =
3984
5564
  configured.path === props.tsconfig ? undefined : configured.path;
3985
5565
  const identities = createHostPathIdentityContext(props.filesystem);
5566
+ // Read from the project's own tsconfig rather than the generated one: a
5567
+ // relative `outDir` is anchored at the config that declares it, and the
5568
+ // generated config lives in a system temp directory. The caller's
5569
+ // compiler-options overlay still wins, since it wins for the compile too.
5570
+ const membershipPolicy = mergeMembershipPolicyOverlay(
5571
+ readProjectMembershipPolicy(props.tsconfig),
5572
+ props.compilerOptions,
5573
+ projectRoot,
5574
+ );
3986
5575
  const before = collectProjectInputSnapshot(
3987
5576
  projectRoot,
3988
5577
  identities,
3989
5578
  props.filesystem,
5579
+ undefined,
5580
+ { policy: membershipPolicy },
3990
5581
  );
3991
5582
  tracker = props.trackProjectMembership
3992
5583
  ? await createProjectMutationTracker(
3993
5584
  before.projectDirectories,
3994
5585
  props.filesystem,
5586
+ membershipPolicy,
3995
5587
  )
3996
5588
  : undefined;
3997
5589
  const result = withTransformScratchEnvironment(scratchDirectory, () =>
@@ -4019,6 +5611,7 @@ async function transformProject(props: {
4019
5611
  filesystem: props.filesystem,
4020
5612
  projectRoot,
4021
5613
  result,
5614
+ scratchDirectory,
4022
5615
  temporaryTsconfig,
4023
5616
  });
4024
5617
  // The generation's absent resolution candidates, which get a watcher of
@@ -4034,6 +5627,7 @@ async function transformProject(props: {
4034
5627
  filesystem: props.filesystem,
4035
5628
  projectRoot,
4036
5629
  result,
5630
+ scratchDirectory,
4037
5631
  temporaryTsconfig,
4038
5632
  })
4039
5633
  : { candidates: [], watched: [] };
@@ -4065,14 +5659,18 @@ async function transformProject(props: {
4065
5659
  : undefined;
4066
5660
  const externalInputPaths = selectExternalInputPaths({
4067
5661
  filesystem: props.filesystem,
5662
+ membershipPolicy,
4068
5663
  projectRoot,
4069
5664
  result,
5665
+ scratchDirectory,
4070
5666
  temporaryTsconfig,
4071
5667
  });
4072
5668
  const inputSnapshot = collectProjectInputSnapshot(
4073
5669
  projectRoot,
4074
5670
  identities,
4075
5671
  props.filesystem,
5672
+ undefined,
5673
+ { policy: membershipPolicy },
4076
5674
  );
4077
5675
  // Whether the recorded snapshot describes one coherent state of the
4078
5676
  // project. A membership event during the compile taints it exactly like an
@@ -4083,6 +5681,7 @@ async function transformProject(props: {
4083
5681
  identities,
4084
5682
  projectRoot,
4085
5683
  result,
5684
+ scratchDirectory,
4086
5685
  });
4087
5686
  const walkStable =
4088
5687
  walkSnapshotComplete(before, declaredInputs) &&
@@ -4111,29 +5710,50 @@ async function transformProject(props: {
4111
5710
  props.currentFile,
4112
5711
  identities,
4113
5712
  );
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];
5713
+ const currentSourceHash = hashText(props.currentSource);
5714
+ const projectInputHashes = { ...inputSnapshot.hashes };
5715
+ if (
5716
+ Object.prototype.hasOwnProperty.call(inputSnapshot.hashes, currentFileKey)
5717
+ ) {
5718
+ inputSnapshot.hashes[currentFileKey] = currentSourceHash;
5719
+ // That overlay makes this one key the only recorded hash a disk signature
5720
+ // cannot stand for: the bytes it names came from the bundler, not the file.
5721
+ delete inputSnapshot.provenSignatures[currentFileKey];
5722
+ }
4118
5723
  const cached: TtscCachedProjectTransform = {
5724
+ // The pass this compile was started for. Its snapshot describes the
5725
+ // project as of this compile, so it is settled for this pass and any
5726
+ // later pass must re-prove it.
5727
+ ...(props.deliveryEpoch === undefined
5728
+ ? {}
5729
+ : { deliveryEpoch: props.deliveryEpoch }),
4119
5730
  // Capture the out-of-walk input hashes while the generation is fresh so
4120
5731
  // cache validation can re-check them; computed before dispose so the
4121
- // exclusion of the temp-dir tsconfig is the only reason it never keys.
5732
+ // scratch-tree exclusion is the only reason its disposed artifacts never
5733
+ // key the persistent generation.
4122
5734
  externalInputHashes: {},
4123
5735
  externalInputRealpaths: {},
4124
5736
  externalInputPaths,
4125
5737
  inputHashes: inputSnapshot.hashes,
4126
5738
  inputSignatures: inputSnapshot.provenSignatures,
5739
+ membershipPolicy,
4127
5740
  projectDirectories: inputSnapshot.projectDirectories,
5741
+ tsconfig: props.tsconfig,
4128
5742
  projectSnapshotComplete: false,
4129
5743
  projectRoot,
4130
5744
  result,
5745
+ scratchDirectory,
4131
5746
  servedFiles: new Set(),
4132
5747
  // Remember the generated temp-dir tsconfig (disposed below) so watch
4133
5748
  // derivation can drop it from the envelope's config chain; a registered
4134
5749
  // but deleted file would invalidate every persistent-cache snapshot.
4135
5750
  ...(temporaryTsconfig === undefined ? {} : { temporaryTsconfig }),
4136
5751
  };
5752
+ cached.sourceHashes = captureTransformSourceHashes(
5753
+ cached,
5754
+ props.currentFile,
5755
+ currentSourceHash,
5756
+ );
4137
5757
  const externalInputSnapshot = captureExternalInputSnapshot(
4138
5758
  cached,
4139
5759
  externalInputPaths,
@@ -4145,23 +5765,52 @@ async function transformProject(props: {
4145
5765
  // cannot be reused can say which evidence it lacked. The extra work runs
4146
5766
  // only on the failing path, where the alternative is recompiling the whole
4147
5767
  // project for every remaining module.
4148
- const graphProofs = matchesCompilerGraphInputProofs(cached);
4149
- const universalInputs =
4150
- captureUniversalHostInputValidation(cached, props.currentFile) !==
4151
- undefined;
5768
+ const failures = createGenerationProofFailures();
5769
+ if (!walkStable) {
5770
+ recordProjectSnapshotFailures(failures, {
5771
+ before,
5772
+ candidateTracker,
5773
+ declared: declaredInputs,
5774
+ hostInputTracker,
5775
+ identities,
5776
+ projectRoot,
5777
+ snapshot: inputSnapshot,
5778
+ tracker,
5779
+ });
5780
+ }
5781
+ const graphFailures = compilerGraphInputProofFailures(cached);
5782
+ mergeGenerationProofFailures(failures, graphFailures);
5783
+ mergeGenerationProofFailures(failures, externalInputSnapshot.failures);
5784
+ const universalInputCapture = captureUniversalHostInputValidation(
5785
+ cached,
5786
+ props.currentFile,
5787
+ );
5788
+ mergeGenerationProofFailures(failures, universalInputCapture.failures);
5789
+ const graphProofs =
5790
+ graphFailures.entries.length === 0 && graphFailures.omitted === 0;
5791
+ const universalInputs = universalInputCapture.validation !== undefined;
4152
5792
  const stableProjectSnapshot =
4153
5793
  walkStable &&
4154
5794
  graphProofs &&
4155
5795
  externalInputSnapshot.complete &&
4156
5796
  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,
5797
+ if (!stableProjectSnapshot) {
5798
+ TRANSFORM_GENERATION_FAILURES.set(result, failures);
5799
+ TRANSFORM_FAILED_GENERATION_VALIDATIONS.set(result, {
5800
+ cached,
5801
+ declaredInputs,
5802
+ inputStates: captureFailedGenerationInputStates(cached, failures),
5803
+ projectInputHashes,
5804
+ projectWalkComplete: walkSnapshotComplete(
5805
+ inputSnapshot,
5806
+ declaredInputs,
5807
+ ),
5808
+ projectWalkFailures: projectWalkFailureFingerprint(
5809
+ inputSnapshot,
5810
+ declaredInputs,
5811
+ projectRoot,
5812
+ identities,
5813
+ ),
4165
5814
  });
4166
5815
  }
4167
5816
  cached.projectSnapshotComplete = stableProjectSnapshot;
@@ -4209,21 +5858,30 @@ async function transformProject(props: {
4209
5858
  }
4210
5859
  }
4211
5860
 
4212
- /** Exclude the disposed overlay tsconfig from live host-input tracking. */
5861
+ /** Exclude disposed transform scratch from live host-input tracking. */
4213
5862
  function selectPersistentHostInputs(props: {
4214
5863
  filesystem: TtscTransformFilesystemOperations;
4215
5864
  projectRoot: string;
4216
5865
  result: ITtscCompilerTransformation;
5866
+ scratchDirectory?: string;
4217
5867
  temporaryTsconfig?: string;
4218
5868
  }): string[] {
4219
5869
  if (props.result.type === "exception") return [];
4220
5870
  const inputs = selectListedFiles(props.projectRoot, props.result.hostInputs);
4221
- if (props.temporaryTsconfig === undefined) return inputs;
5871
+ if (
5872
+ props.scratchDirectory === undefined &&
5873
+ props.temporaryTsconfig === undefined
5874
+ )
5875
+ return inputs;
4222
5876
  const identities = createHostPathIdentityContext(props.filesystem);
4223
- const temporary = pathIdentityKey(props.temporaryTsconfig, identities);
4224
- return inputs.filter(
4225
- (input) => pathIdentityKey(input, identities) !== temporary,
4226
- );
5877
+ const temporary =
5878
+ props.temporaryTsconfig === undefined
5879
+ ? undefined
5880
+ : pathIdentityKey(props.temporaryTsconfig, identities);
5881
+ return inputs.filter((input) => {
5882
+ if (isTransformScratchInput(input, props.scratchDirectory)) return false;
5883
+ return pathIdentityKey(input, identities) !== temporary;
5884
+ });
4227
5885
  }
4228
5886
 
4229
5887
  function createTransformTsconfig(
@@ -4344,6 +6002,17 @@ function pathIsWithin(child: string, parent: string): boolean {
4344
6002
  );
4345
6003
  }
4346
6004
 
6005
+ /** Whether an input is owned by the disposable transform scratch tree. */
6006
+ function isTransformScratchInput(
6007
+ input: string,
6008
+ scratchDirectory: string | undefined,
6009
+ ): boolean {
6010
+ return (
6011
+ scratchDirectory !== undefined &&
6012
+ pathIsWithin(path.resolve(input), path.resolve(scratchDirectory))
6013
+ );
6014
+ }
6015
+
4347
6016
  /** Route all compiler/plugin scratch to one owned directory outside project. */
4348
6017
  function transformScratchEnvironment(directory: string): NodeJS.ProcessEnv {
4349
6018
  return {
@@ -4513,10 +6182,40 @@ function readPaths(value: unknown): Record<string, string[]> {
4513
6182
  function createAliasPaths(aliases: unknown): Record<string, string[]> {
4514
6183
  const paths: Record<string, string[]> = {};
4515
6184
  for (const alias of normalizeAliases(aliases)) {
4516
- if (typeof alias.find !== "string" || alias.find.length === 0) {
6185
+ if (typeof alias.find !== "string") {
6186
+ // Vite's array form accepts a `RegExp` find, and `{ find: /^~/ }` is a
6187
+ // common way to spell a prefix alias. A tsconfig `paths` map has no
6188
+ // regular-expression form, so there is nothing to translate it into
6189
+ // (samchon/ttsc#1315). Reducing the simple prefix cases to a string is
6190
+ // possible in principle and deliberately not done: telling `/^~/` from
6191
+ // `/^~(?=\/)/` or `/^@app/` — which matches `@apple` too — means
6192
+ // implementing enough of a regular-expression engine that a wrong
6193
+ // reduction becomes likely, and a mistranslated alias resolves imports to
6194
+ // the wrong file silently, which is worse than not forwarding it.
6195
+ //
6196
+ // Not reported, unlike the wildcard below, and that asymmetry is the
6197
+ // whole point: Vite merges two `RegExp` aliases of its own into every
6198
+ // resolved config, `/^\/?@vite\/env/` and `/^\/?@vite\/client/`. Measured
6199
+ // on a bare project with no user aliases at all, `resolve.alias` has
6200
+ // exactly those two entries under both `serve` and `build`, so a report
6201
+ // on this form would fire twice for every Vite user in every build, name
6202
+ // aliases they never wrote, and say nothing about their configuration.
6203
+ // A diagnostic that cannot distinguish the user's input from the host's
6204
+ // is noise, and noise is what teaches people to stop reading the channel
6205
+ // the out-of-program report depends on. The documentation carries this
6206
+ // form instead, in both README and guide.
6207
+ continue;
6208
+ }
6209
+ if (alias.find.length === 0) {
4517
6210
  continue;
4518
6211
  }
4519
6212
  if (alias.find.includes("*")) {
6213
+ // A `paths` key reads `*` as its own wildcard, so forwarding a `find`
6214
+ // that already contains one cannot preserve the caller's meaning.
6215
+ reportUntranslatableAlias(
6216
+ JSON.stringify(alias.find),
6217
+ 'a "paths" key already reads "*" as its own wildcard',
6218
+ );
4520
6219
  continue;
4521
6220
  }
4522
6221
  const key = alias.find.replace(/\/+$/, "");
@@ -4534,9 +6233,54 @@ function createAliasPaths(aliases: unknown): Record<string, string[]> {
4534
6233
  return paths;
4535
6234
  }
4536
6235
 
4537
- function normalizeAliases(aliases: unknown): TtscTransformAlias[] {
6236
+ /**
6237
+ * Alias descriptions already reported in this process.
6238
+ *
6239
+ * The message is about configuration rather than about a module:
6240
+ * `resolve.alias` is resolved once and then consulted on every delivery, so
6241
+ * reporting per delivery would repeat one statement about the config for every
6242
+ * file in the bundle. Keyed by the description, so a Vite dev server that
6243
+ * reloads its config reports again only when the alias itself changed.
6244
+ */
6245
+ const REPORTED_UNTRANSLATABLE_ALIASES = new Set<string>();
6246
+
6247
+ /**
6248
+ * Tell the user once that an alias they declared is not reaching the compile.
6249
+ *
6250
+ * A dropped alias is not silent in its consequence — the compile resolves
6251
+ * through the tsconfig's own `paths`, and a module that resolves for the
6252
+ * bundler but not for the compiler surfaces as the out-of-program report
6253
+ * (samchon/ttsc#1308) — but that report names the module, not the alias, so the
6254
+ * user cannot learn from it that a configuration they wrote was ignored.
6255
+ *
6256
+ * Only the wildcard form reaches here. Every entry it names was written by the
6257
+ * user, because nothing injects one; the `RegExp` form is left to the
6258
+ * documentation precisely because Vite does inject those, and
6259
+ * {@link createAliasPaths} carries that measurement.
6260
+ */
6261
+ function reportUntranslatableAlias(description: string, reason: string): void {
6262
+ if (REPORTED_UNTRANSLATABLE_ALIASES.has(description)) {
6263
+ return;
6264
+ }
6265
+ REPORTED_UNTRANSLATABLE_ALIASES.add(description);
6266
+ process.stderr.write(
6267
+ `ttsc: the Vite alias ${description} was not forwarded to the compile, because ${reason}. Declare it in your tsconfig's "paths" if ttsc must resolve through it.\n`,
6268
+ );
6269
+ }
6270
+
6271
+ /**
6272
+ * Collect the host's declared aliases without deciding which of them can be
6273
+ * expressed as `paths`.
6274
+ *
6275
+ * That decision belongs to {@link createAliasPaths} alone. It used to be split:
6276
+ * this function's type guard required a string `find` and dropped Vite's
6277
+ * `RegExp` form before `createAliasPaths` ever saw it, which left
6278
+ * `createAliasPaths`'s own non-string branch unreachable and put the drop
6279
+ * somewhere nothing could report it (samchon/ttsc#1315).
6280
+ */
6281
+ function normalizeAliases(aliases: unknown): TtscDeclaredAlias[] {
4538
6282
  if (Array.isArray(aliases)) {
4539
- return aliases.filter(isAlias);
6283
+ return aliases.filter(isDeclaredAlias);
4540
6284
  }
4541
6285
  if (typeof aliases === "object" && aliases !== null) {
4542
6286
  return Object.entries(aliases)
@@ -4594,13 +6338,12 @@ function isRelativeSpecifier(value: string): boolean {
4594
6338
  );
4595
6339
  }
4596
6340
 
4597
- function isAlias(value: unknown): value is TtscTransformAlias {
6341
+ function isDeclaredAlias(value: unknown): value is TtscDeclaredAlias {
4598
6342
  return (
4599
6343
  typeof value === "object" &&
4600
6344
  value !== null &&
4601
6345
  "find" in value &&
4602
6346
  "replacement" in value &&
4603
- typeof value.find === "string" &&
4604
6347
  typeof value.replacement === "string"
4605
6348
  );
4606
6349
  }
@@ -4618,6 +6361,7 @@ function selectTransformedSource(props: {
4618
6361
  file: string;
4619
6362
  projectRoot: string;
4620
6363
  result: ITtscCompilerTransformation;
6364
+ tsconfig: string;
4621
6365
  }): string {
4622
6366
  if (props.result.type === "exception") {
4623
6367
  throw new Error(formatUnknownError(props.result.error));
@@ -4648,20 +6392,68 @@ function selectTransformedSource(props: {
4648
6392
  if (source !== undefined) {
4649
6393
  return source;
4650
6394
  }
4651
- throw new Error(`ttsc transform did not return output for ${props.file}`);
6395
+ throw new TtscMissingProgramOutputError(props.file, props.tsconfig);
6396
+ }
6397
+
6398
+ /**
6399
+ * Tell the user once that a module was left untransformed, and why.
6400
+ *
6401
+ * The condition is ordinary and the build continues, but it must never be
6402
+ * silent: a file the program does not contain keeps whatever plugin syntax it
6403
+ * carries, so a typia `assert<T>()` in it becomes a runtime failure rather than
6404
+ * a build failure. One line per file per generation per pass, on the channel
6405
+ * the generation's other non-fatal diagnostics already use, so a bundle that
6406
+ * reaches many such files does not repeat itself per delivery.
6407
+ */
6408
+ function reportMissingProgramOutput(
6409
+ cached: TtscCachedProjectTransform,
6410
+ error: TtscMissingProgramOutputError,
6411
+ epoch: number | undefined,
6412
+ ): void {
6413
+ const reported = (cached.missingOutputReported ??= new Set<string>());
6414
+ if (cached.missingOutputEpoch !== epoch) {
6415
+ cached.missingOutputEpoch = epoch;
6416
+ reported.clear();
6417
+ }
6418
+ if (reported.has(error.file)) {
6419
+ return;
6420
+ }
6421
+ reported.add(error.file);
6422
+ process.stderr.write(`${error.message}
6423
+ `);
4652
6424
  }
4653
6425
 
4654
6426
  /**
4655
- * Forward non-fatal plugin diagnostics to stderr.
6427
+ * Forward non-fatal plugin diagnostics to stderr, once per generation per pass.
4656
6428
  *
4657
6429
  * A `success` result may still carry warnings or informational messages from
4658
- * plugins. These are surfaced via stderr rather than throwing so the build
4659
- * continues. Failures and exceptions are handled by the caller.
6430
+ * plugins `@ttsc/lint` reports every rule below error severity this way.
6431
+ * These are surfaced via stderr rather than throwing so the build continues.
6432
+ * Failures and exceptions are handled by the caller.
6433
+ *
6434
+ * They describe one compile of one program, so writing them per delivery
6435
+ * printed the same warning once per module and scaled the noise with exactly
6436
+ * the reuse the cache exists to provide (samchon/ttsc#1304). A pass that reuses
6437
+ * a retained generation still surfaces them once, because a build's warnings
6438
+ * are part of what that build reports; a host with no pass boundary surfaces
6439
+ * them once per generation, which is the same rule with one pass.
4660
6440
  */
4661
- function reportSuccessDiagnostics(result: ITtscCompilerTransformation): void {
6441
+ function reportSuccessDiagnostics(
6442
+ cached: TtscCachedProjectTransform,
6443
+ epoch: number | undefined,
6444
+ ): void {
6445
+ const result = cached.result;
4662
6446
  if (result.type !== "success" || result.diagnostics === undefined) {
4663
6447
  return;
4664
6448
  }
6449
+ if (
6450
+ cached.diagnosticsReported === true &&
6451
+ cached.diagnosticsEpoch === epoch
6452
+ ) {
6453
+ return;
6454
+ }
6455
+ cached.diagnosticsReported = true;
6456
+ cached.diagnosticsEpoch = epoch;
4665
6457
  const text = formatDiagnostics(result.diagnostics);
4666
6458
  if (text.length !== 0) {
4667
6459
  process.stderr.write(`${text}\n`);
@@ -4687,7 +6479,7 @@ function formatDiagnostics(diagnostics: ITtscCompilerDiagnostic[]): string {
4687
6479
  diag.line === undefined
4688
6480
  ? undefined
4689
6481
  : `${diag.line}:${diag.character ?? 1}`,
4690
- diag.messageText,
6482
+ stripTerminalEscapes(diag.messageText),
4691
6483
  ]
4692
6484
  .filter((part) => part !== undefined && part !== "")
4693
6485
  .join(": "),
@@ -4697,7 +6489,7 @@ function formatDiagnostics(diagnostics: ITtscCompilerDiagnostic[]): string {
4697
6489
 
4698
6490
  function formatUnknownError(error: unknown): string {
4699
6491
  if (error instanceof Error) {
4700
- return error.message;
6492
+ return stripTerminalEscapes(error.message);
4701
6493
  }
4702
6494
  if (
4703
6495
  typeof error === "object" &&
@@ -4705,9 +6497,33 @@ function formatUnknownError(error: unknown): string {
4705
6497
  "message" in error &&
4706
6498
  typeof error.message === "string"
4707
6499
  ) {
4708
- return error.message;
6500
+ return stripTerminalEscapes(error.message);
4709
6501
  }
4710
- return String(error);
6502
+ return stripTerminalEscapes(String(error));
6503
+ }
6504
+
6505
+ /**
6506
+ * Remove terminal colour and cursor sequences from text the adapter surfaces.
6507
+ *
6508
+ * An ordinary type error reaches the adapter as an `"exception"` envelope whose
6509
+ * `error` is the host's own rendered output, colour and all, and the envelope
6510
+ * carries no structured diagnostics to format instead. What the adapter hands
6511
+ * back is not going to a terminal: it becomes the `Error` a bundler reports, so
6512
+ * it lands in a Vite overlay, a webpack error report or a CI annotation, where
6513
+ * the escapes render as literal noise around the file and line the reader needs
6514
+ * (samchon/ttsc#1312).
6515
+ *
6516
+ * The colour originates in the host's rendering rather than in anything this
6517
+ * adapter configures, so this is the adapter-side repair, applied to every
6518
+ * message it surfaces rather than to one call site.
6519
+ */
6520
+ function stripTerminalEscapes(text: string): string {
6521
+ // Built from a char code so no control byte lives in this source file, and
6522
+ // written with `[[]` (a class holding one literal bracket) so the pattern
6523
+ // needs no backslash escapes to survive the string it is assembled from.
6524
+ const escape = String.fromCharCode(27);
6525
+ const controlSequence = new RegExp(escape + "[[][0-9;?]*[ -/]*[@-~]", "g");
6526
+ return text.replace(controlSequence, "");
4711
6527
  }
4712
6528
 
4713
6529
  /**