@ttsc/unplugin 0.28.3 → 0.28.5

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 +70 -8
  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 +117 -21
  10. package/lib/core/index.js.map +1 -1
  11. package/lib/core/index.mjs +115 -21
  12. package/lib/core/index.mjs.map +1 -1
  13. package/lib/core/transform.d.cts +93 -25
  14. package/lib/core/transform.d.mts +93 -25
  15. package/lib/core/transform.d.ts +93 -25
  16. package/lib/core/transform.js +803 -121
  17. package/lib/core/transform.js.map +1 -1
  18. package/lib/core/transform.mjs +804 -122
  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 +122 -21
  43. package/src/core/transform.ts +1073 -132
  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,22 +38,51 @@ 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
 
@@ -131,8 +164,52 @@ interface TtscFailedGenerationValidation {
131
164
  projectWalkFailures: string;
132
165
  }
133
166
 
134
- /** A bounded proof failure that stays authoritative until its inputs change. */
135
- class TtscUnstableGenerationError extends Error {
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 {
136
213
  public readonly validation: TtscFailedGenerationValidation;
137
214
 
138
215
  public constructor(
@@ -145,6 +222,56 @@ class TtscUnstableGenerationError extends Error {
145
222
  }
146
223
  }
147
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
+
148
275
  /** Proof witnesses retained beside a compiler result without extending its API. */
149
276
  const TRANSFORM_GENERATION_FAILURES = new WeakMap<
150
277
  ITtscCompilerTransformation,
@@ -157,10 +284,10 @@ const TRANSFORM_FAILED_GENERATION_VALIDATIONS = new WeakMap<
157
284
  TtscFailedGenerationValidation
158
285
  >();
159
286
 
160
- /** Rejected cache promises whose unchanged terminal verdict may be replayed. */
287
+ /** Cache promises whose unchanged terminal verdict may be replayed. */
161
288
  const TERMINAL_TRANSFORM_GENERATIONS = new WeakMap<
162
289
  Promise<TtscCachedProjectTransform>,
163
- TtscUnstableGenerationError
290
+ TtscTerminalGenerationError
164
291
  >();
165
292
 
166
293
  /** Maximum witnesses printed and retained for each failed transform attempt. */
@@ -228,6 +355,28 @@ export interface TtscCachedProjectTransform {
228
355
  * transform.
229
356
  */
230
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;
231
380
  /**
232
381
  * Metadata signature of each {@link inputHashes} entry whose hash was proven
233
382
  * against an unracing read of the file on disk, in a tick the observed
@@ -290,10 +439,35 @@ export interface TtscCachedProjectTransform {
290
439
  projectRoot: string;
291
440
  /** Raw compiler output returned by {@link TtscCompiler.transform}. */
292
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;
293
466
  /**
294
467
  * Files already delivered from this generation, keyed by filesystem identity.
295
- * Build-scoped caches use this to skip persistent validation only for a
296
- * 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.
297
471
  */
298
472
  servedFiles?: Set<string>;
299
473
  /**
@@ -387,10 +561,30 @@ const TRANSFORM_RESULT_FILESYSTEM = new WeakMap<
387
561
  >();
388
562
 
389
563
  /**
390
- * Caches whose owner has declared a real per-build lifecycle by calling
391
- * {@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.
392
579
  */
393
- 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
+ }
394
588
 
395
589
  function createHostPathIdentityContext(
396
590
  filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
@@ -453,28 +647,41 @@ function resultFilesystem(
453
647
  }
454
648
 
455
649
  /**
456
- * Start a host build, clearing its prior generation and enabling constant-time
457
- * 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.
458
662
  *
459
- * Hosts without a guaranteed build-start callback use persistent validation
460
- * 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,
461
665
  * defines one process-scoped module-loading session.
462
666
  */
463
667
  export function beginTtscTransformBuild(cache: TtscTransformCache): void {
464
- clearTtscTransformCache(cache);
465
- BUILD_SCOPED_TRANSFORM_CACHES.add(cache);
668
+ TRANSFORM_CACHE_EPOCHS.set(
669
+ cache,
670
+ (TRANSFORM_CACHE_EPOCHS.get(cache) ?? 0) + 1,
671
+ );
466
672
  }
467
673
 
468
674
  /**
469
- * 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.
470
677
  *
471
- * This is distinct from {@link beginTtscTransformBuild}: hosts such as Vite's
472
- * development server may invoke `buildStart` only once for a process that spans
473
- * 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.
474
681
  */
475
682
  export function resetTtscTransformCache(cache: TtscTransformCache): void {
476
683
  clearTtscTransformCache(cache);
477
- BUILD_SCOPED_TRANSFORM_CACHES.delete(cache);
684
+ TRANSFORM_CACHE_EPOCHS.delete(cache);
478
685
  }
479
686
 
480
687
  /** Dispose generation-owned filesystem resources before clearing a cache. */
@@ -589,16 +796,20 @@ export async function transformTtsc(
589
796
  });
590
797
 
591
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);
592
805
  let transformed = cache?.get(key);
593
806
  if (transformed !== undefined) {
594
807
  const terminal = TERMINAL_TRANSFORM_GENERATIONS.get(transformed);
595
808
  if (terminal !== undefined) {
596
- // A proof failure is a verdict about one observed environment, not an
597
- // invitation for every later module to repeat the whole compile. Keep
598
- // replaying it until a source/input probe or an explicit cache reset
599
- // establishes that a new generation could differ.
809
+ // A terminal verdict is an answer about one observed environment, not an
810
+ // invitation for every later module to repeat the whole compile.
600
811
  if (
601
- !failedGenerationEnvironmentChanged(terminal.validation, {
812
+ replaysTerminalGeneration(terminal, epoch, {
602
813
  currentFile: file,
603
814
  currentSource: source,
604
815
  filesystem,
@@ -621,9 +832,7 @@ export async function transformTtsc(
621
832
  if (cache?.get(key) !== transformed) {
622
833
  continue;
623
834
  }
624
- const buildScoped =
625
- cache !== undefined && BUILD_SCOPED_TRANSFORM_CACHES.has(cache);
626
- if (!buildScoped) {
835
+ if (epoch === undefined) {
627
836
  await settleProjectMutationEvents(cached);
628
837
  if (cache?.get(key) !== transformed) {
629
838
  continue;
@@ -638,16 +847,33 @@ export async function transformTtsc(
638
847
  projectRoot: cached.projectRoot,
639
848
  result: cached.result,
640
849
  }) &&
641
- matchesCachedSource(cached, file, source, buildScoped)
850
+ matchesCachedSource(cached, file, source, epoch)
642
851
  ) {
643
- reportSuccessDiagnostics(cached.result);
852
+ reportSuccessDiagnostics(cached, epoch);
644
853
  // A resolved `"exception"` / `"failure"` envelope makes this throw;
645
- // that is a failed generation too, so evict before surfacing it.
646
- const code = selectOrEvict(cache, key, transformed, {
647
- file,
648
- projectRoot: cached.projectRoot,
649
- result: cached.result,
650
- });
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
+ }
651
877
  notifyWatchInputs(hooks, cached, file);
652
878
  markCachedSourceServed(cached, file);
653
879
  return createTransformResult(source, code);
@@ -668,6 +894,10 @@ export async function transformTtsc(
668
894
  compilerOptions: options.compilerOptions,
669
895
  currentFile: file,
670
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,
671
901
  filesystem,
672
902
  plugins: options.plugins,
673
903
  trackProjectMembership: cache !== undefined,
@@ -681,12 +911,24 @@ export async function transformTtsc(
681
911
  continue;
682
912
  }
683
913
  const { projectRoot, result } = cached;
684
- reportSuccessDiagnostics(result);
685
- const code = selectOrEvict(cache, key, generation, {
686
- file,
687
- projectRoot,
688
- result,
689
- });
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
+ }
690
932
  notifyWatchInputs(hooks, cached, file);
691
933
  markCachedSourceServed(cached, file);
692
934
  if (
@@ -730,29 +972,146 @@ async function awaitOrEvict(
730
972
  }
731
973
 
732
974
  /**
733
- * Extract the transformed source, evicting the generation when the result is a
734
- * host `"exception"` or compiler `"failure"` (which makes
735
- * {@link selectTransformedSource} throw). Such a failed generation must not be
736
- * 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.
737
990
  */
738
991
  function selectOrEvict(
739
992
  cache: TtscTransformCache | undefined,
740
993
  key: string,
741
994
  generation: Promise<TtscCachedProjectTransform>,
995
+ epoch: number | undefined,
742
996
  props: {
743
997
  file: string;
744
998
  projectRoot: string;
745
999
  result: ITtscCompilerTransformation;
1000
+ tsconfig: string;
746
1001
  },
747
1002
  ): string {
748
1003
  try {
749
1004
  return selectTransformedSource(props);
750
1005
  } catch (error) {
751
- 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
+ }
752
1025
  throw error;
753
1026
  }
754
1027
  }
755
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
+
756
1115
  /**
757
1116
  * Delete a failed generation from the cache only when it is still the entry
758
1117
  * stored under `key`. The identity check prevents an older failed generation's
@@ -1204,6 +1563,51 @@ function collectDeclaredIdentities(
1204
1563
  * transform scratch tree (see
1205
1564
  * {@link TtscCachedProjectTransform.scratchDirectory}).
1206
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.
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
+
1207
1611
  function notifyWatchInputs(
1208
1612
  hooks: TtscTransformHooks | undefined,
1209
1613
  cached: TtscCachedProjectTransform,
@@ -1703,20 +2107,21 @@ export function createTransformResult(
1703
2107
  * state.
1704
2108
  *
1705
2109
  * Always compares the current module's in-memory source with the generation
1706
- * snapshot. A cache whose owner called {@link beginTtscTransformBuild} can use
1707
- * that comparison alone for a stable generation's first module delivery in the
1708
- * current build. An incomplete generation may not take this shortcut: otherwise
1709
- * a sibling output captured during a filesystem race could still be served
1710
- * once. Later graph-bearing requests validate the file's derived input set and
1711
- * project membership; graph-free envelopes conservatively re-hash the complete
1712
- * 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
1713
2118
  * re-transform.
1714
2119
  */
1715
2120
  function matchesCachedSource(
1716
2121
  cached: TtscCachedProjectTransform,
1717
2122
  file: string,
1718
2123
  source: string,
1719
- buildScoped: boolean,
2124
+ epoch: number | undefined,
1720
2125
  ): boolean {
1721
2126
  const identities = envelopeDerivation(cached).identityContext;
1722
2127
  const currentKey = toProjectKey(cached.projectRoot, file, identities);
@@ -1728,12 +2133,24 @@ function matchesCachedSource(
1728
2133
  if (expected !== hashText(source)) {
1729
2134
  return false;
1730
2135
  }
1731
- if (
1732
- buildScoped &&
1733
- cached.projectSnapshotComplete === true &&
1734
- !cached.servedFiles?.has(pathIdentityKey(file, identities))
1735
- ) {
1736
- 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
+ }
1737
2154
  }
1738
2155
  if (
1739
2156
  cached.result.type !== "exception" &&
@@ -2590,6 +3007,12 @@ function matchesCompleteInputSnapshot(
2590
3007
  cached.inputSignatures === undefined
2591
3008
  ? undefined
2592
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
+ },
2593
3016
  );
2594
3017
  if (!walkSnapshotComplete(current, declaredInputs)) {
2595
3018
  return false;
@@ -2961,9 +3384,17 @@ export function collectProjectInputHashes(
2961
3384
  projectRoot: string,
2962
3385
  identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
2963
3386
  filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
3387
+ policy?: ITtscProjectMembershipPolicy,
2964
3388
  ): Record<string, string> {
2965
- return collectProjectInputSnapshot(projectRoot, identities, filesystem)
2966
- .hashes;
3389
+ return collectProjectInputSnapshot(
3390
+ projectRoot,
3391
+ identities,
3392
+ filesystem,
3393
+ undefined,
3394
+ {
3395
+ policy,
3396
+ },
3397
+ ).hashes;
2967
3398
  }
2968
3399
 
2969
3400
  /** Hash project files and snapshot the directory topology in one walk. */
@@ -2975,6 +3406,16 @@ function collectProjectInputSnapshot(
2975
3406
  hashes: Record<string, string>;
2976
3407
  signatures: Record<string, string>;
2977
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
+ },
2978
3419
  ): {
2979
3420
  complete: boolean;
2980
3421
  directoryComplete: boolean;
@@ -2990,13 +3431,26 @@ function collectProjectInputSnapshot(
2990
3431
  const provenSignatures: Record<string, string> = {};
2991
3432
  const unstableFiles = new Set<string>();
2992
3433
  let attributed = true;
2993
- const walked = walkProjectInputs(projectRoot, filesystem);
3434
+ const walked = walkProjectInputs(projectRoot, filesystem, options?.policy);
2994
3435
  const walkFailures = [...walked.failures];
2995
3436
  let complete = walked.complete;
2996
3437
  for (const file of walked.files) {
2997
3438
  try {
2998
- const before = inputMetadataEvidence(file, filesystem);
2999
3439
  const key = toProjectKey(projectRoot, file, identities);
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);
3000
3454
  // A file whose signature still equals the one captured around the read
3001
3455
  // that produced the recorded hash carries that content, so the whole
3002
3456
  // project does not have to be re-read to prove one delivery. A signature
@@ -3061,8 +3515,9 @@ function collectProjectInputSnapshot(
3061
3515
  }
3062
3516
 
3063
3517
  /**
3064
- * Enumerate every regular file under `root`, skipping well-known output and
3065
- * 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}).
3066
3521
  *
3067
3522
  * Uses an iterative DFS instead of `fs.readdirSync` recursion to avoid
3068
3523
  * unbounded call-stack depth on deep project trees. The result is sorted so
@@ -3071,6 +3526,7 @@ function collectProjectInputSnapshot(
3071
3526
  function walkProjectInputs(
3072
3527
  root: string,
3073
3528
  filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
3529
+ policy: ITtscProjectMembershipPolicy = PERMISSIVE_PROJECT_MEMBERSHIP_POLICY,
3074
3530
  ): {
3075
3531
  complete: boolean;
3076
3532
  directories: TtscProjectDirectorySnapshot[];
@@ -3078,9 +3534,18 @@ function walkProjectInputs(
3078
3534
  files: string[];
3079
3535
  } {
3080
3536
  let complete = true;
3081
- const directories: TtscProjectDirectorySnapshot[] = [];
3082
3537
  const failures: TtscProjectWalkFailure[] = [];
3083
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
+ }[] = [];
3084
3549
  const stack = [root];
3085
3550
  while (stack.length !== 0) {
3086
3551
  const current = stack.pop()!;
@@ -3112,34 +3577,108 @@ function walkProjectInputs(
3112
3577
  path: current,
3113
3578
  });
3114
3579
  }
3115
- directories.push({
3580
+ const visit = {
3581
+ childDirectories: [] as string[],
3582
+ entries: [] as { name: string; kind: string; possible: boolean }[],
3583
+ ownInput: false,
3116
3584
  path: current,
3117
3585
  // If membership moved during enumeration, force the next delivery to
3118
3586
  // replace this generation instead of blessing a torn directory/file
3119
3587
  // snapshot as stable.
3120
- signature:
3588
+ stable:
3121
3589
  after !== undefined && before === after
3122
- ? after
3590
+ ? undefined
3123
3591
  : `unstable:${before}:${after ?? "missing"}`,
3124
- });
3592
+ };
3125
3593
  for (const entry of entries) {
3126
3594
  if (isIgnoredProjectDirectory(entry.name)) {
3127
3595
  continue;
3128
3596
  }
3129
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
+ });
3130
3611
  if (entry.isDirectory()) {
3612
+ visit.childDirectories.push(file);
3131
3613
  stack.push(file);
3132
- } 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).
3133
3623
  files.push(file);
3624
+ visit.ownInput = true;
3134
3625
  }
3135
3626
  }
3627
+ visited.push(visit);
3136
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
+ }
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
+ });
3137
3669
  directories.sort((left, right) => left.path.localeCompare(right.path));
3138
3670
  files.sort();
3139
3671
  return { complete, directories, failures, files };
3140
3672
  }
3141
3673
 
3142
- /** 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
+ */
3143
3682
  function projectDirectorySignature(
3144
3683
  directory: string,
3145
3684
  filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
@@ -3170,14 +3709,31 @@ function sameProjectDirectories(
3170
3709
  left: readonly TtscProjectDirectorySnapshot[],
3171
3710
  right: readonly TtscProjectDirectorySnapshot[],
3172
3711
  ): boolean {
3173
- return (
3174
- left.length === right.length &&
3175
- left.every(
3176
- (directory, index) =>
3177
- directory.path === right[index]?.path &&
3178
- directory.signature === right[index]?.signature,
3179
- )
3180
- );
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;
3181
3737
  }
3182
3738
 
3183
3739
  /**
@@ -3209,6 +3765,7 @@ function openDirectoryWatch(
3209
3765
  async function createProjectMutationTracker(
3210
3766
  directories: readonly TtscProjectDirectorySnapshot[],
3211
3767
  filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
3768
+ policy: ITtscProjectMembershipPolicy = PERMISSIVE_PROJECT_MEMBERSHIP_POLICY,
3212
3769
  ): Promise<TtscProjectMutationTracker> {
3213
3770
  const tracker: TtscProjectMutationTracker = {
3214
3771
  changes: new Set(),
@@ -3223,6 +3780,13 @@ async function createProjectMutationTracker(
3223
3780
  directories.map((directory) => ({ directory: directory.path })),
3224
3781
  false,
3225
3782
  filesystem,
3783
+ (location, filename) =>
3784
+ reportsProgramMembership(
3785
+ path.join(location, filename),
3786
+ filename,
3787
+ policy,
3788
+ filesystem,
3789
+ ),
3226
3790
  );
3227
3791
  return tracker;
3228
3792
  }
@@ -3238,14 +3802,26 @@ async function createProjectMutationTracker(
3238
3802
  filesystem,
3239
3803
  directory.path,
3240
3804
  (eventType, filename) => {
3241
- if (eventType === "rename") {
3242
- recordProjectMutation(
3243
- tracker,
3244
- filename === null
3245
- ? directory.path
3246
- : path.join(directory.path, filename),
3247
- );
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;
3248
3818
  }
3819
+ recordProjectMutation(
3820
+ tracker,
3821
+ filename === null
3822
+ ? directory.path
3823
+ : path.join(directory.path, filename),
3824
+ );
3249
3825
  },
3250
3826
  () => {
3251
3827
  tracker.failed = true;
@@ -3361,6 +3937,90 @@ async function createHostInputMutationTracker(
3361
3937
  return tracker;
3362
3938
  }
3363
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
+
3364
4024
  /** Record enough exact mutation evidence without retaining an event stream. */
3365
4025
  function recordProjectMutation(
3366
4026
  tracker: TtscProjectMutationTracker,
@@ -3387,7 +4047,24 @@ interface WindowsProjectMutationBroker {
3387
4047
  trackers: Map<
3388
4048
  number,
3389
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;
3390
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>;
3391
4068
  tracker: TtscProjectMutationTracker;
3392
4069
  }
3393
4070
  >;
@@ -3412,8 +4089,21 @@ async function registerWindowsProjectMutationTracker(
3412
4089
  locations: readonly WindowsMutationLocation[],
3413
4090
  allEvents: boolean,
3414
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,
3415
4098
  ): Promise<void> {
3416
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>();
3417
4107
  const normalized = locations.map((location) => {
3418
4108
  let directory: string;
3419
4109
  try {
@@ -3421,6 +4111,7 @@ async function registerWindowsProjectMutationTracker(
3421
4111
  } catch {
3422
4112
  directory = path.resolve(location.directory);
3423
4113
  }
4114
+ spellings.set(directory, location.directory);
3424
4115
  return {
3425
4116
  directory,
3426
4117
  ...(location.names === undefined ? {} : { names: location.names }),
@@ -3434,7 +4125,12 @@ async function registerWindowsProjectMutationTracker(
3434
4125
  const ready = new Promise<void>((resolve) => {
3435
4126
  resolveReady = resolve;
3436
4127
  });
3437
- broker.trackers.set(id, { ready: resolveReady, tracker });
4128
+ broker.trackers.set(id, {
4129
+ membership,
4130
+ ready: resolveReady,
4131
+ spellings,
4132
+ tracker,
4133
+ });
3438
4134
  tracker.drain = () => drainWindowsProjectMutationBroker(broker);
3439
4135
  tracker.close = () => {
3440
4136
  const active = broker.trackers.get(id);
@@ -3529,11 +4225,22 @@ function getWindowsProjectMutationBroker(): WindowsProjectMutationBroker {
3529
4225
  if (record.ready === true) registration.ready();
3530
4226
  if (record.ready !== true && record.failed !== true) {
3531
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
+ }
3532
4239
  recordProjectMutation(
3533
4240
  registration.tracker,
3534
4241
  typeof record.filename === "string"
3535
- ? path.join(record.directory, record.filename)
3536
- : record.directory,
4242
+ ? path.join(reported, record.filename)
4243
+ : reported,
3537
4244
  );
3538
4245
  } else {
3539
4246
  registration.tracker.membershipChanged = true;
@@ -3741,6 +4448,7 @@ export function isProjectWalkPath(
3741
4448
  file: string,
3742
4449
  _identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
3743
4450
  filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
4451
+ policy: ITtscProjectMembershipPolicy = PERMISSIVE_PROJECT_MEMBERSHIP_POLICY,
3744
4452
  ): boolean {
3745
4453
  // Walk membership is lexical. Resolving `file` to physical identity first
3746
4454
  // would turn `root/alias/value.ts` into `root/target/value.ts`, hide the
@@ -3757,7 +4465,20 @@ export function isProjectWalkPath(
3757
4465
  return false;
3758
4466
  }
3759
4467
  const segments = relative.split(path.sep);
3760
- 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)) {
3761
4482
  return false;
3762
4483
  }
3763
4484
  let current = resolvedRoot;
@@ -3889,6 +4610,7 @@ function matchesCachedExternalInputs(cached: TtscCachedProjectTransform): {
3889
4610
  */
3890
4611
  function selectExternalInputPaths(props: {
3891
4612
  filesystem?: TtscTransformFilesystemOperations;
4613
+ membershipPolicy: ITtscProjectMembershipPolicy;
3892
4614
  projectRoot: string;
3893
4615
  result: ITtscCompilerTransformation;
3894
4616
  scratchDirectory?: string;
@@ -3970,7 +4692,13 @@ function selectExternalInputPaths(props: {
3970
4692
  isTransformScratchInput(absolute, props.scratchDirectory) ||
3971
4693
  seen.has(spelling) ||
3972
4694
  (!missingCandidate &&
3973
- isProjectWalkPath(props.projectRoot, absolute, identities, filesystem))
4695
+ isProjectWalkPath(
4696
+ props.projectRoot,
4697
+ absolute,
4698
+ identities,
4699
+ filesystem,
4700
+ props.membershipPolicy,
4701
+ ))
3974
4702
  ) {
3975
4703
  continue;
3976
4704
  }
@@ -4155,22 +4883,57 @@ function insideProject(directory: string, projectRoot: string): boolean {
4155
4883
  const NOTIFIABLE_ABSENCE_DIRECTORY_LIMIT = 512;
4156
4884
 
4157
4885
  function isIgnoredProjectDirectory(name: string): boolean {
4158
- return (
4159
- name === ".git" ||
4160
- name === ".ttsc" ||
4161
- name === ".cache" ||
4162
- name === ".next" ||
4163
- name === ".nuxt" ||
4164
- name === ".svelte-kit" ||
4165
- name === ".turbo" ||
4166
- name === ".vite" ||
4167
- name === "build" ||
4168
- name === "coverage" ||
4169
- name === "dist" ||
4170
- name === "node_modules" ||
4171
- name === "out" ||
4172
- name === "temp" ||
4173
- 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),
4174
4937
  );
4175
4938
  }
4176
4939
 
@@ -4626,6 +5389,8 @@ function failedGenerationEnvironmentChanged(
4626
5389
  validation.cached.projectRoot,
4627
5390
  identities,
4628
5391
  props.filesystem,
5392
+ undefined,
5393
+ { policy: validation.cached.membershipPolicy },
4629
5394
  );
4630
5395
  if (
4631
5396
  validation.projectWalkComplete !==
@@ -4725,6 +5490,11 @@ async function transformProject(props: {
4725
5490
  compilerOptions: Record<string, unknown>;
4726
5491
  currentFile: string;
4727
5492
  currentSource: string;
5493
+ /**
5494
+ * Delivery pass this compile was started for; see
5495
+ * {@link TtscCachedProjectTransform.deliveryEpoch}.
5496
+ */
5497
+ deliveryEpoch?: number;
4728
5498
  filesystem: TtscTransformFilesystemOperations;
4729
5499
  plugins?: ResolvedTtscUnpluginOptions["plugins"];
4730
5500
  trackProjectMembership: boolean;
@@ -4771,6 +5541,7 @@ async function captureTransformGeneration(props: {
4771
5541
  compilerOptions: Record<string, unknown>;
4772
5542
  currentFile: string;
4773
5543
  currentSource: string;
5544
+ deliveryEpoch?: number;
4774
5545
  filesystem: TtscTransformFilesystemOperations;
4775
5546
  plugins?: ResolvedTtscUnpluginOptions["plugins"];
4776
5547
  trackProjectMembership: boolean;
@@ -4792,15 +5563,27 @@ async function captureTransformGeneration(props: {
4792
5563
  const temporaryTsconfig =
4793
5564
  configured.path === props.tsconfig ? undefined : configured.path;
4794
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
+ );
4795
5575
  const before = collectProjectInputSnapshot(
4796
5576
  projectRoot,
4797
5577
  identities,
4798
5578
  props.filesystem,
5579
+ undefined,
5580
+ { policy: membershipPolicy },
4799
5581
  );
4800
5582
  tracker = props.trackProjectMembership
4801
5583
  ? await createProjectMutationTracker(
4802
5584
  before.projectDirectories,
4803
5585
  props.filesystem,
5586
+ membershipPolicy,
4804
5587
  )
4805
5588
  : undefined;
4806
5589
  const result = withTransformScratchEnvironment(scratchDirectory, () =>
@@ -4876,6 +5659,7 @@ async function captureTransformGeneration(props: {
4876
5659
  : undefined;
4877
5660
  const externalInputPaths = selectExternalInputPaths({
4878
5661
  filesystem: props.filesystem,
5662
+ membershipPolicy,
4879
5663
  projectRoot,
4880
5664
  result,
4881
5665
  scratchDirectory,
@@ -4885,6 +5669,8 @@ async function captureTransformGeneration(props: {
4885
5669
  projectRoot,
4886
5670
  identities,
4887
5671
  props.filesystem,
5672
+ undefined,
5673
+ { policy: membershipPolicy },
4888
5674
  );
4889
5675
  // Whether the recorded snapshot describes one coherent state of the
4890
5676
  // project. A membership event during the compile taints it exactly like an
@@ -4935,6 +5721,12 @@ async function captureTransformGeneration(props: {
4935
5721
  delete inputSnapshot.provenSignatures[currentFileKey];
4936
5722
  }
4937
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 }),
4938
5730
  // Capture the out-of-walk input hashes while the generation is fresh so
4939
5731
  // cache validation can re-check them; computed before dispose so the
4940
5732
  // scratch-tree exclusion is the only reason its disposed artifacts never
@@ -4944,7 +5736,9 @@ async function captureTransformGeneration(props: {
4944
5736
  externalInputPaths,
4945
5737
  inputHashes: inputSnapshot.hashes,
4946
5738
  inputSignatures: inputSnapshot.provenSignatures,
5739
+ membershipPolicy,
4947
5740
  projectDirectories: inputSnapshot.projectDirectories,
5741
+ tsconfig: props.tsconfig,
4948
5742
  projectSnapshotComplete: false,
4949
5743
  projectRoot,
4950
5744
  result,
@@ -5388,10 +6182,40 @@ function readPaths(value: unknown): Record<string, string[]> {
5388
6182
  function createAliasPaths(aliases: unknown): Record<string, string[]> {
5389
6183
  const paths: Record<string, string[]> = {};
5390
6184
  for (const alias of normalizeAliases(aliases)) {
5391
- 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) {
5392
6210
  continue;
5393
6211
  }
5394
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
+ );
5395
6219
  continue;
5396
6220
  }
5397
6221
  const key = alias.find.replace(/\/+$/, "");
@@ -5409,9 +6233,54 @@ function createAliasPaths(aliases: unknown): Record<string, string[]> {
5409
6233
  return paths;
5410
6234
  }
5411
6235
 
5412
- 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[] {
5413
6282
  if (Array.isArray(aliases)) {
5414
- return aliases.filter(isAlias);
6283
+ return aliases.filter(isDeclaredAlias);
5415
6284
  }
5416
6285
  if (typeof aliases === "object" && aliases !== null) {
5417
6286
  return Object.entries(aliases)
@@ -5469,13 +6338,12 @@ function isRelativeSpecifier(value: string): boolean {
5469
6338
  );
5470
6339
  }
5471
6340
 
5472
- function isAlias(value: unknown): value is TtscTransformAlias {
6341
+ function isDeclaredAlias(value: unknown): value is TtscDeclaredAlias {
5473
6342
  return (
5474
6343
  typeof value === "object" &&
5475
6344
  value !== null &&
5476
6345
  "find" in value &&
5477
6346
  "replacement" in value &&
5478
- typeof value.find === "string" &&
5479
6347
  typeof value.replacement === "string"
5480
6348
  );
5481
6349
  }
@@ -5493,6 +6361,7 @@ function selectTransformedSource(props: {
5493
6361
  file: string;
5494
6362
  projectRoot: string;
5495
6363
  result: ITtscCompilerTransformation;
6364
+ tsconfig: string;
5496
6365
  }): string {
5497
6366
  if (props.result.type === "exception") {
5498
6367
  throw new Error(formatUnknownError(props.result.error));
@@ -5523,20 +6392,68 @@ function selectTransformedSource(props: {
5523
6392
  if (source !== undefined) {
5524
6393
  return source;
5525
6394
  }
5526
- throw new Error(`ttsc transform did not return output for ${props.file}`);
6395
+ throw new TtscMissingProgramOutputError(props.file, props.tsconfig);
5527
6396
  }
5528
6397
 
5529
6398
  /**
5530
- * Forward non-fatal plugin diagnostics to stderr.
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
+ `);
6424
+ }
6425
+
6426
+ /**
6427
+ * Forward non-fatal plugin diagnostics to stderr, once per generation per pass.
5531
6428
  *
5532
6429
  * A `success` result may still carry warnings or informational messages from
5533
- * plugins. These are surfaced via stderr rather than throwing so the build
5534
- * 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.
5535
6440
  */
5536
- function reportSuccessDiagnostics(result: ITtscCompilerTransformation): void {
6441
+ function reportSuccessDiagnostics(
6442
+ cached: TtscCachedProjectTransform,
6443
+ epoch: number | undefined,
6444
+ ): void {
6445
+ const result = cached.result;
5537
6446
  if (result.type !== "success" || result.diagnostics === undefined) {
5538
6447
  return;
5539
6448
  }
6449
+ if (
6450
+ cached.diagnosticsReported === true &&
6451
+ cached.diagnosticsEpoch === epoch
6452
+ ) {
6453
+ return;
6454
+ }
6455
+ cached.diagnosticsReported = true;
6456
+ cached.diagnosticsEpoch = epoch;
5540
6457
  const text = formatDiagnostics(result.diagnostics);
5541
6458
  if (text.length !== 0) {
5542
6459
  process.stderr.write(`${text}\n`);
@@ -5562,7 +6479,7 @@ function formatDiagnostics(diagnostics: ITtscCompilerDiagnostic[]): string {
5562
6479
  diag.line === undefined
5563
6480
  ? undefined
5564
6481
  : `${diag.line}:${diag.character ?? 1}`,
5565
- diag.messageText,
6482
+ stripTerminalEscapes(diag.messageText),
5566
6483
  ]
5567
6484
  .filter((part) => part !== undefined && part !== "")
5568
6485
  .join(": "),
@@ -5572,7 +6489,7 @@ function formatDiagnostics(diagnostics: ITtscCompilerDiagnostic[]): string {
5572
6489
 
5573
6490
  function formatUnknownError(error: unknown): string {
5574
6491
  if (error instanceof Error) {
5575
- return error.message;
6492
+ return stripTerminalEscapes(error.message);
5576
6493
  }
5577
6494
  if (
5578
6495
  typeof error === "object" &&
@@ -5580,9 +6497,33 @@ function formatUnknownError(error: unknown): string {
5580
6497
  "message" in error &&
5581
6498
  typeof error.message === "string"
5582
6499
  ) {
5583
- return error.message;
6500
+ return stripTerminalEscapes(error.message);
5584
6501
  }
5585
- 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, "");
5586
6527
  }
5587
6528
 
5588
6529
  /**