@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
@@ -9,6 +9,115 @@ var ttsc = require('ttsc');
9
9
  var pathIdentity = require('ttsc/path-identity');
10
10
  var tsconfigPaths = require('./tsconfigPaths.js');
11
11
 
12
+ /**
13
+ * A verdict about one generation that later deliveries replay instead of
14
+ * repeating the whole compile behind it.
15
+ *
16
+ * The two kinds are replayed on different evidence, and each carries its own: a
17
+ * pass verdict knows the pass it belongs to, and an unstable generation knows
18
+ * the recorded environment it was proven against.
19
+ */
20
+ class TtscTerminalGenerationError extends Error {
21
+ }
22
+ /**
23
+ * The compile succeeded and produced no output for one requested module,
24
+ * because the program does not contain it.
25
+ *
26
+ * Not a terminal generation error, and deliberately not a build failure. It is
27
+ * a fact about one file, and the answer to it is to leave that file to the host
28
+ * (samchon/ttsc#1308). It is a distinct type rather than a message match so the
29
+ * decision travels as a type: `@ttsc/metro` used to recognise this case by
30
+ * searching the message text for "did not return output", which is how one
31
+ * product came to hold two different answers to one condition.
32
+ */
33
+ class TtscMissingProgramOutputError extends Error {
34
+ /** The module the bundler asked for. */
35
+ file;
36
+ /** The project config whose program does not contain it. */
37
+ tsconfig;
38
+ constructor(file, tsconfig) {
39
+ super(`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.`);
40
+ this.name = "TtscMissingProgramOutputError";
41
+ this.file = file;
42
+ this.tsconfig = tsconfig;
43
+ }
44
+ }
45
+ /**
46
+ * A bounded proof failure that stays authoritative until its inputs change.
47
+ *
48
+ * This is the adapter failing to _obtain_ a coherent snapshot — a race it lost
49
+ * — so a later attempt may well succeed with the same inputs. It is retried
50
+ * when its recorded environment moves, and a new delivery epoch grants it the
51
+ * one fresh attempt the per-pass cache clear used to give it
52
+ * (samchon/ttsc#1300).
53
+ */
54
+ class TtscUnstableGenerationError extends TtscTerminalGenerationError {
55
+ validation;
56
+ constructor(message, validation) {
57
+ super(message);
58
+ this.name = "TtscUnstableGenerationError";
59
+ this.validation = validation;
60
+ }
61
+ }
62
+ /**
63
+ * A compile this pass already attempted, whose envelope failed outright.
64
+ *
65
+ * The envelope cannot say whether the host reported diagnostics about the
66
+ * project or failed to run at all: an ordinary type error arrives as an
67
+ * `"exception"` carrying the compiler's own diagnostic text, exactly as a
68
+ * crashed host would. Sniffing that message to tell the two apart would be a
69
+ * guess, so the adapter uses the one boundary it genuinely owns. Inside a pass
70
+ * the answer is already settled, so every later module replays it instead of
71
+ * repeating a whole-project transform to reach the same verdict, which is what
72
+ * made a single broken save cost one compile per delivered module
73
+ * (samchon/ttsc#1303).
74
+ *
75
+ * The scope is exactly the pass. A host whose `buildStart` repeats drops the
76
+ * verdict at its next rebuild, so a transient host failure costs that one
77
+ * rebuild. A host with no pass boundary never retains one at all and keeps
78
+ * retrying on its very next delivery. Between them sits a host that opens
79
+ * exactly one pass for its whole process — Bun's runtime plugin, and a Vite dev
80
+ * server configured with `server.watch: null` — where the verdict lasts the
81
+ * session. That follows from what those hosts already publish about themselves,
82
+ * that their session is one immutable load session and the remedy for changed
83
+ * inputs is to restart, and it is the deliberate trade: without it, one type
84
+ * error costs such a session a whole-project compile per delivered module,
85
+ * which is the workload samchon/ttsc#970 is about.
86
+ *
87
+ * It carries the original error's message, stack and `cause` rather than
88
+ * replacing them, so what a bundler reports is what it reported before the
89
+ * verdict existed.
90
+ */
91
+ class TtscPassVerdictError extends TtscTerminalGenerationError {
92
+ /** The delivery pass this verdict belongs to, and its whole scope. */
93
+ epoch;
94
+ constructor(original, epoch) {
95
+ super(original instanceof Error
96
+ ? original.message
97
+ : formatUnknownError(original), { cause: original });
98
+ if (original instanceof Error) {
99
+ this.name = original.name;
100
+ if (original.stack !== undefined)
101
+ this.stack = original.stack;
102
+ }
103
+ else {
104
+ this.name = "TtscPassVerdictError";
105
+ }
106
+ this.epoch = epoch;
107
+ }
108
+ }
109
+ /** Proof witnesses retained beside a compiler result without extending its API. */
110
+ const TRANSFORM_GENERATION_FAILURES = new WeakMap();
111
+ /** Retry baselines retained only for attempts that could not be published. */
112
+ const TRANSFORM_FAILED_GENERATION_VALIDATIONS = new WeakMap();
113
+ /** Cache promises whose unchanged terminal verdict may be replayed. */
114
+ const TERMINAL_TRANSFORM_GENERATIONS = new WeakMap();
115
+ /** Maximum witnesses printed and retained for each failed transform attempt. */
116
+ const MAX_GENERATION_PROOF_FAILURES = 8;
117
+ /** Maximum exact mutation paths kept after a tracker already proved a change. */
118
+ const MAX_GENERATION_MUTATION_PATHS = 8;
119
+ /** One retry absorbs a transient watch write without admitting an infinite loop. */
120
+ const TRANSFORM_GENERATION_ATTEMPTS = 2;
12
121
  const DEFAULT_FILESYSTEM_OPERATIONS = Object.freeze({
13
122
  exists: fs.existsSync,
14
123
  lstat: (location) => fs.lstatSync(location, { bigint: true }),
@@ -21,10 +130,27 @@ const DEFAULT_FILESYSTEM_OPERATIONS = Object.freeze({
21
130
  const TRANSFORM_CACHE_FILESYSTEM = new WeakMap();
22
131
  const TRANSFORM_RESULT_FILESYSTEM = new WeakMap();
23
132
  /**
24
- * Caches whose owner has declared a real per-build lifecycle by calling
25
- * {@link beginTtscTransformBuild} before transforms begin.
133
+ * The current delivery epoch of each cache whose owner has declared a real
134
+ * per-pass lifecycle by calling {@link beginTtscTransformBuild}.
135
+ *
136
+ * A _delivery epoch_ is one bundler pass: the window inside which each module
137
+ * is requested at most once, so its first delivery may be settled against the
138
+ * state the pass started from. It is deliberately not the same fact as whether
139
+ * the generation is still valid, which the recorded snapshot answers.
140
+ * Conflating the two is what made every host with a repeating `buildStart` —
141
+ * webpack and Rspack watch, Rollup and Rolldown watch, `vite build --watch`,
142
+ * esbuild rebuild — discard a perfectly good whole-project compile on every
143
+ * edit (samchon/ttsc#1300).
144
+ *
145
+ * Absent from the map means persistent validation: a host with no pass boundary
146
+ * at all (a watching Vite dev server, Metro, the Turbopack loader), where every
147
+ * delivery proves the generation for itself.
26
148
  */
27
- const BUILD_SCOPED_TRANSFORM_CACHES = new WeakSet();
149
+ const TRANSFORM_CACHE_EPOCHS = new WeakMap();
150
+ /** The pass a delivery belongs to, or `undefined` under persistent validation. */
151
+ function transformCacheEpoch(cache) {
152
+ return cache === undefined ? undefined : TRANSFORM_CACHE_EPOCHS.get(cache);
153
+ }
28
154
  function createHostPathIdentityContext(filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
29
155
  return pathIdentity.createFilesystemPathIdentityContext({
30
156
  caseSensitive: filesystem.caseSensitive,
@@ -64,27 +190,37 @@ function resultFilesystem(result) {
64
190
  return (TRANSFORM_RESULT_FILESYSTEM.get(result) ?? DEFAULT_FILESYSTEM_OPERATIONS);
65
191
  }
66
192
  /**
67
- * Start a host build, clearing its prior generation and enabling constant-time
68
- * first delivery for modules compiled during this build.
193
+ * Open a new delivery pass, enabling constant-time first delivery for every
194
+ * module this pass asks for.
195
+ *
196
+ * This deliberately retains the cached generation. The pass boundary is a
197
+ * statement about _deliveries_ — each module is requested at most once inside
198
+ * it — not about whether the compiled program is still correct, which the
199
+ * generation's own recorded snapshot answers and which
200
+ * {@link matchesCachedSource} proves once at the pass's first delivery. Clearing
201
+ * here instead made a host whose `buildStart` repeats recompile the whole
202
+ * project on every rebuild even when no compiler input had changed
203
+ * (samchon/ttsc#1300). Use {@link resetTtscTransformCache} to actually discard a
204
+ * generation and its watchers.
69
205
  *
70
- * Hosts without a guaranteed build-start callback use persistent validation
71
- * unless they have another immutable lifecycle. Bun runtime setup, for example,
206
+ * Hosts without a guaranteed pass boundary use persistent validation unless
207
+ * they have another immutable lifecycle. Bun runtime setup, for example,
72
208
  * defines one process-scoped module-loading session.
73
209
  */
74
210
  function beginTtscTransformBuild(cache) {
75
- clearTtscTransformCache(cache);
76
- BUILD_SCOPED_TRANSFORM_CACHES.add(cache);
211
+ TRANSFORM_CACHE_EPOCHS.set(cache, (TRANSFORM_CACHE_EPOCHS.get(cache) ?? 0) + 1);
77
212
  }
78
213
  /**
79
- * Clear a cache and return it to persistent validation mode.
214
+ * Discard every generation, dispose its watchers, and return the cache to
215
+ * persistent validation mode.
80
216
  *
81
- * This is distinct from {@link beginTtscTransformBuild}: hosts such as Vite's
82
- * development server may invoke `buildStart` only once for a process that spans
83
- * many edits, so that callback cannot authorize build-scoped shortcuts.
217
+ * This is the unconditional lifecycle boundary, and it is distinct from
218
+ * {@link beginTtscTransformBuild}: a pass ending is not a reason to throw a
219
+ * proven compile away, while a session ending is.
84
220
  */
85
221
  function resetTtscTransformCache(cache) {
86
222
  clearTtscTransformCache(cache);
87
- BUILD_SCOPED_TRANSFORM_CACHES.delete(cache);
223
+ TRANSFORM_CACHE_EPOCHS.delete(cache);
88
224
  }
89
225
  /** Dispose generation-owned filesystem resources before clearing a cache. */
90
226
  function clearTtscTransformCache(cache) {
@@ -140,10 +276,33 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
140
276
  tsconfig,
141
277
  });
142
278
  for (;;) {
279
+ // Read once per iteration, before the cache is consulted, so a delivery
280
+ // belongs to the pass that was current when it started examining the
281
+ // generation. A pass opened while this one awaits an in-flight compile is
282
+ // picked up by the next iteration, which is the one that runs when the
283
+ // entry it awaited turns out to have been superseded.
284
+ const epoch = transformCacheEpoch(cache);
143
285
  let transformed = cache?.get(key);
144
286
  if (transformed !== undefined) {
145
- // A rejected in-flight generation must not stay cached: evict it (only if
146
- // it is still the current entry) so a later call re-runs the transform.
287
+ const terminal = TERMINAL_TRANSFORM_GENERATIONS.get(transformed);
288
+ if (terminal !== undefined) {
289
+ // A terminal verdict is an answer about one observed environment, not an
290
+ // invitation for every later module to repeat the whole compile.
291
+ if (replaysTerminalGeneration(terminal, epoch, {
292
+ currentFile: file,
293
+ currentSource: source,
294
+ filesystem,
295
+ })) {
296
+ throw terminal;
297
+ }
298
+ evictGeneration(cache, key, transformed);
299
+ if (cache?.get(key) !== undefined) {
300
+ continue;
301
+ }
302
+ transformed = undefined;
303
+ }
304
+ }
305
+ if (transformed !== undefined) {
147
306
  const cached = await awaitOrEvict(cache, key, transformed);
148
307
  TRANSFORM_RESULT_FILESYSTEM.set(cached.result, filesystem);
149
308
  // While this caller awaited the old Promise, another caller may have
@@ -151,8 +310,7 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
151
310
  if (cache?.get(key) !== transformed) {
152
311
  continue;
153
312
  }
154
- const buildScoped = cache !== undefined && BUILD_SCOPED_TRANSFORM_CACHES.has(cache);
155
- if (!buildScoped) {
313
+ if (epoch === undefined) {
156
314
  await settleProjectMutationEvents(cached);
157
315
  if (cache?.get(key) !== transformed) {
158
316
  continue;
@@ -167,15 +325,33 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
167
325
  projectRoot: cached.projectRoot,
168
326
  result: cached.result,
169
327
  }) &&
170
- matchesCachedSource(cached, file, source, buildScoped)) {
171
- reportSuccessDiagnostics(cached.result);
328
+ matchesCachedSource(cached, file, source, epoch)) {
329
+ reportSuccessDiagnostics(cached, epoch);
172
330
  // A resolved `"exception"` / `"failure"` envelope makes this throw;
173
- // that is a failed generation too, so evict before surfacing it.
174
- const code = selectOrEvict(cache, key, transformed, {
175
- file,
176
- projectRoot: cached.projectRoot,
177
- result: cached.result,
178
- });
331
+ // that is a failed generation too, so it is retained for this pass or
332
+ // evicted outside one before being surfaced.
333
+ let code;
334
+ try {
335
+ code = selectOrEvict(cache, key, transformed, epoch, {
336
+ file,
337
+ projectRoot: cached.projectRoot,
338
+ result: cached.result,
339
+ tsconfig: cached.tsconfig,
340
+ });
341
+ }
342
+ catch (error) {
343
+ if (!(error instanceof TtscMissingProgramOutputError)) {
344
+ notifyFailedGenerationInputs(hooks, cached);
345
+ throw error;
346
+ }
347
+ // The compile is fine and simply has nothing for this module, so the
348
+ // module goes back to the host untransformed rather than failing the
349
+ // build (samchon/ttsc#1308). It still counts as delivered in this
350
+ // pass, and there is nothing to watch for a file with no output.
351
+ reportMissingProgramOutput(cached, error, epoch);
352
+ markCachedSourceServed(cached, file);
353
+ return undefined;
354
+ }
179
355
  notifyWatchInputs(hooks, cached, file);
180
356
  markCachedSourceServed(cached, file);
181
357
  return createTransformResult(source, code);
@@ -195,6 +371,10 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
195
371
  compilerOptions: options.compilerOptions,
196
372
  currentFile: file,
197
373
  currentSource: source,
374
+ // Stamp the pass this compile was started for, not the one it happens
375
+ // to finish in: a boundary crossed mid-compile leaves the generation
376
+ // belonging to the earlier pass, so the next pass re-proves it.
377
+ deliveryEpoch: epoch,
198
378
  filesystem,
199
379
  plugins: options.plugins,
200
380
  trackProjectMembership: cache !== undefined,
@@ -208,12 +388,25 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
208
388
  continue;
209
389
  }
210
390
  const { projectRoot, result } = cached;
211
- reportSuccessDiagnostics(result);
212
- const code = selectOrEvict(cache, key, generation, {
213
- file,
214
- projectRoot,
215
- result,
216
- });
391
+ reportSuccessDiagnostics(cached, epoch);
392
+ let code;
393
+ try {
394
+ code = selectOrEvict(cache, key, generation, epoch, {
395
+ file,
396
+ projectRoot,
397
+ result,
398
+ tsconfig: cached.tsconfig,
399
+ });
400
+ }
401
+ catch (error) {
402
+ if (!(error instanceof TtscMissingProgramOutputError)) {
403
+ notifyFailedGenerationInputs(hooks, cached);
404
+ throw error;
405
+ }
406
+ reportMissingProgramOutput(cached, error, epoch);
407
+ markCachedSourceServed(cached, file);
408
+ return undefined;
409
+ }
217
410
  notifyWatchInputs(hooks, cached, file);
218
411
  markCachedSourceServed(cached, file);
219
412
  if (isVolatileFile(envelopeDerivation(cached), { file, projectRoot, result })) {
@@ -223,38 +416,133 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
223
416
  }
224
417
  }
225
418
  /**
226
- * Await a cached generation, evicting it on rejection.
419
+ * Await a cached generation, retaining only terminal proof failures.
227
420
  *
228
421
  * The cache stores the in-flight transform Promise before it settles so
229
- * concurrent callers share one compilation. A rejected generation must not
230
- * remain the authoritative cached result, or a transient toolchain/host failure
231
- * becomes permanent for a long-lived worker. Eviction is identity-guarded so a
232
- * newer generation another caller installed under the same key survives.
422
+ * concurrent callers share one compilation. Ordinary compiler and host
423
+ * rejections are evicted so a transient failure cannot become permanent. A
424
+ * bounded stabilization failure is different: it already spent its retry and
425
+ * repeating it for every later module recreates the issue this gate prevents.
426
+ * It stays authoritative until its retained input baseline changes or the cache
427
+ * owner starts a new lifecycle.
233
428
  */
234
429
  async function awaitOrEvict(cache, key, generation) {
235
430
  try {
236
431
  return await generation;
237
432
  }
238
433
  catch (error) {
239
- evictGeneration(cache, key, generation);
434
+ if (error instanceof TtscUnstableGenerationError &&
435
+ cache?.get(key) === generation) {
436
+ TERMINAL_TRANSFORM_GENERATIONS.set(generation, error);
437
+ }
438
+ else {
439
+ evictGeneration(cache, key, generation);
440
+ }
240
441
  throw error;
241
442
  }
242
443
  }
243
444
  /**
244
- * Extract the transformed source, evicting the generation when the result is a
245
- * host `"exception"` or compiler `"failure"` (which makes
246
- * {@link selectTransformedSource} throw). Such a failed generation must not be
247
- * replayed to later callers of an unchanged module.
445
+ * Extract the transformed source, and decide what a throwing generation is.
446
+ *
447
+ * {@link selectTransformedSource} throws for two different reasons, and only one
448
+ * of them is about the generation. A host `"exception"` or a compiler
449
+ * `"failure"` means the compile produced nothing for anyone: inside a pass that
450
+ * verdict is retained and replayed, because evicting it made every remaining
451
+ * module repeat the whole-project transform only to reach the identical answer
452
+ * (samchon/ttsc#1303), and outside a pass it keeps being evicted so a
453
+ * long-lived worker retries on its very next delivery exactly as before.
454
+ *
455
+ * A `"success"` envelope that has no output for the module asking is the other
456
+ * reason, and it is a fact about that one file: an ordinary condition for a
457
+ * module the bundle reaches and the tsconfig program does not contain. It is
458
+ * neither retained nor evicted. The error reaches the caller, and the
459
+ * generation, which compiled perfectly well for every other module, stays.
248
460
  */
249
- function selectOrEvict(cache, key, generation, props) {
461
+ function selectOrEvict(cache, key, generation, epoch, props) {
250
462
  try {
251
463
  return selectTransformedSource(props);
252
464
  }
253
465
  catch (error) {
254
- evictGeneration(cache, key, generation);
466
+ const verdict = retainPassVerdict(cache, key, generation, epoch, props.result, error);
467
+ if (verdict !== undefined) {
468
+ throw verdict;
469
+ }
470
+ // A generation that compiled fine and simply has no output for the module
471
+ // asking is not a failed generation. Discarding it made every later module
472
+ // recompile the whole project to reach the same answer, which is the cost
473
+ // samchon/ttsc#1303 is about, for a bundle that merely reaches a file the
474
+ // tsconfig program does not contain.
475
+ if (props.result.type !== "success") {
476
+ evictGeneration(cache, key, generation);
477
+ }
255
478
  throw error;
256
479
  }
257
480
  }
481
+ /**
482
+ * Retain the verdict of a compile this pass already attempted, or return
483
+ * `undefined` when nothing may be retained.
484
+ *
485
+ * Only inside a delivery pass. A pass is the window in which every delivery is
486
+ * settled against the state the pass started from, so an attempt it already
487
+ * made is part of that state and the remaining modules replay it rather than
488
+ * each repeating a whole-project transform to reach the same answer. Outside a
489
+ * pass there is no such window, and a long-lived worker must keep retrying on
490
+ * its very next delivery so a transient host failure never becomes permanent.
491
+ */
492
+ function retainPassVerdict(cache, key, generation, epoch, result, error) {
493
+ // Only an envelope that failed outright is a statement about the generation.
494
+ // `selectTransformedSource` also throws for a file the compile simply has no
495
+ // output for, which is an ordinary condition for a module the bundle reaches
496
+ // but the tsconfig program does not contain, and which says nothing about the
497
+ // other modules. Retaining that would fail the whole pass, naming a file none
498
+ // of them asked about.
499
+ if (result.type === "success" ||
500
+ epoch === undefined ||
501
+ cache?.get(key) !== generation) {
502
+ return undefined;
503
+ }
504
+ const existing = TERMINAL_TRANSFORM_GENERATIONS.get(generation);
505
+ if (existing !== undefined) {
506
+ return existing;
507
+ }
508
+ const verdict = new TtscPassVerdictError(error, epoch);
509
+ TERMINAL_TRANSFORM_GENERATIONS.set(generation, verdict);
510
+ return verdict;
511
+ }
512
+ /**
513
+ * Whether a terminal verdict still answers for this delivery.
514
+ *
515
+ * Inside the pass that produced or confirmed it, it is replayed without
516
+ * re-probing anything: the pass settles every delivery against the state it
517
+ * started from, so re-walking the project once per module would spend exactly
518
+ * the cost this gate exists to remove.
519
+ *
520
+ * Across passes the two kinds part company. A pass verdict is dropped, because
521
+ * a new pass is the first boundary at which the host itself claims something
522
+ * may have changed, and the compile it stood for was never proven against a
523
+ * recorded environment. An unstable generation was, so it keeps its own rule:
524
+ * one fresh attempt per pass, and otherwise replayed until that recorded
525
+ * environment provably moves.
526
+ */
527
+ function replaysTerminalGeneration(terminal, epoch, props) {
528
+ if (terminal instanceof TtscPassVerdictError) {
529
+ // A pass verdict has no recorded environment to re-confirm against, so the
530
+ // pass that produced it is its whole scope.
531
+ return epoch !== undefined && terminal.epoch === epoch;
532
+ }
533
+ if (!(terminal instanceof TtscUnstableGenerationError)) {
534
+ return false;
535
+ }
536
+ // An unstable generation does have one, and confirming it per delivery is the
537
+ // behaviour its own contract describes, so the pass does not cache that
538
+ // answer. A new pass still grants the fresh attempt the per-pass cache clear
539
+ // used to give it.
540
+ if (epoch !== undefined &&
541
+ terminal.validation.cached.deliveryEpoch !== epoch) {
542
+ return false;
543
+ }
544
+ return !failedGenerationEnvironmentChanged(terminal.validation, props);
545
+ }
258
546
  /**
259
547
  * Delete a failed generation from the cache only when it is still the entry
260
548
  * stored under `key`. The identity check prevents an older failed generation's
@@ -318,6 +606,7 @@ function envelopeGraphIndexes(state, props) {
318
606
  members: new Set(),
319
607
  speculative: new Set(),
320
608
  inputProofs: new Map(),
609
+ inputProofFailures: new Map(),
321
610
  inputProofConflicts: new Set(),
322
611
  };
323
612
  const graph = props.result.type === "exception" ? undefined : props.result.graph;
@@ -335,7 +624,11 @@ function envelopeGraphIndexes(state, props) {
335
624
  .filter((target) => typeof target === "string" && target.length !== 0)
336
625
  .map((target) => {
337
626
  const absoluteTarget = path.resolve(props.projectRoot, target);
338
- built.members.add(derivationIdentity(state, absoluteTarget));
627
+ const targetIdentity = derivationIdentity(state, absoluteTarget);
628
+ built.members.add(targetIdentity);
629
+ if (!built.spellings.has(targetIdentity)) {
630
+ built.spellings.set(targetIdentity, absoluteTarget);
631
+ }
339
632
  return absoluteTarget;
340
633
  }));
341
634
  built.edges.set(identity, entries);
@@ -343,7 +636,10 @@ function envelopeGraphIndexes(state, props) {
343
636
  built.globals.push(...selectListedFiles(props.projectRoot, graph.globals));
344
637
  built.configs.push(...selectListedFiles(props.projectRoot, graph.configs));
345
638
  for (const input of [...built.globals, ...built.configs]) {
346
- built.members.add(derivationIdentity(state, input));
639
+ const identity = derivationIdentity(state, input);
640
+ built.members.add(identity);
641
+ if (!built.spellings.has(identity))
642
+ built.spellings.set(identity, input);
347
643
  }
348
644
  const candidateEntries = Object.entries(graph.candidates ?? {}).filter((entry) => Array.isArray(entry[1]));
349
645
  // Every candidate source is an importing file the compiler read, so fold
@@ -351,7 +647,12 @@ function envelopeGraphIndexes(state, props) {
351
647
  // candidate could be classified speculative before a later entry proves
352
648
  // the same path is a realized source.
353
649
  for (const [source] of candidateEntries) {
354
- built.members.add(derivationIdentity(state, path.resolve(props.projectRoot, source)));
650
+ const absoluteSource = path.resolve(props.projectRoot, source);
651
+ const identity = derivationIdentity(state, absoluteSource);
652
+ built.members.add(identity);
653
+ if (!built.spellings.has(identity)) {
654
+ built.spellings.set(identity, absoluteSource);
655
+ }
355
656
  }
356
657
  const realized = new Set(built.members);
357
658
  for (const [source, candidates] of candidateEntries) {
@@ -369,6 +670,17 @@ function envelopeGraphIndexes(state, props) {
369
670
  if (!realized.has(identity))
370
671
  built.speculative.add(identity);
371
672
  built.members.add(identity);
673
+ if (!built.spellings.has(identity)) {
674
+ built.spellings.set(identity, path.resolve(props.projectRoot, candidate));
675
+ }
676
+ }
677
+ }
678
+ const transformSources = new Set();
679
+ if (props.result.type === "success") {
680
+ for (const output of Object.keys(props.result.typescript)) {
681
+ if (!isDeclarationFile(output)) {
682
+ transformSources.add(derivationIdentity(state, path.resolve(props.projectRoot, output)));
683
+ }
372
684
  }
373
685
  }
374
686
  for (const [input, hash] of Object.entries(graph.inputHashes ?? {})) {
@@ -388,8 +700,9 @@ function envelopeGraphIndexes(state, props) {
388
700
  }
389
701
  const absolute = path.resolve(props.projectRoot, input);
390
702
  const identity = derivationIdentity(state, absolute);
391
- if (!built.members.has(identity))
703
+ if (!built.members.has(identity) && !transformSources.has(identity)) {
392
704
  continue;
705
+ }
393
706
  const proof = {
394
707
  hash,
395
708
  path: absolute,
@@ -406,6 +719,23 @@ function envelopeGraphIndexes(state, props) {
406
719
  built.inputProofs.set(identity, proof);
407
720
  }
408
721
  }
722
+ for (const [input, reason] of Object.entries(graph.inputProofFailures ?? {})) {
723
+ if (typeof reason !== "string" || !/^[a-z0-9-]{1,64}$/.test(reason)) {
724
+ continue;
725
+ }
726
+ const absolute = path.resolve(props.projectRoot, input);
727
+ const identity = derivationIdentity(state, absolute);
728
+ if (!built.members.has(identity) && !transformSources.has(identity)) {
729
+ continue;
730
+ }
731
+ if (built.inputProofs.has(identity)) {
732
+ built.inputProofs.delete(identity);
733
+ built.inputProofConflicts.add(identity);
734
+ }
735
+ if (!built.inputProofFailures.has(identity)) {
736
+ built.inputProofFailures.set(identity, reason);
737
+ }
738
+ }
409
739
  }
410
740
  state.graph = built;
411
741
  return built;
@@ -452,9 +782,51 @@ function collectDeclaredIdentities(state, projectRoot, listed) {
452
782
  * Envelope keys mirror the `typescript` keys (project-relative); values may be
453
783
  * project-relative or absolute. Every path is absolutized against the project
454
784
  * root and deduplicated; the file itself is dropped (the bundler already
455
- * watches the module it transforms), and so is the disposed temp-dir tsconfig
456
- * (see {@link TtscCachedProjectTransform.temporaryTsconfig}).
785
+ * watches the module it transforms), and so is every path in the disposed
786
+ * transform scratch tree (see
787
+ * {@link TtscCachedProjectTransform.scratchDirectory}).
788
+ */
789
+ /**
790
+ * Register the failed generation's own project inputs so the host can observe
791
+ * the fix.
792
+ *
793
+ * A successful delivery registers the derived watch inputs, which is how a
794
+ * type-only file that no bundler graph contains still invalidates the modules
795
+ * depending on it. A failed one used to register nothing: `selectWatchInputs`
796
+ * returns an empty list for an `"exception"` envelope, and the throw happens
797
+ * before `notifyWatchInputs` is reached at all. When the failing compile is the
798
+ * first of a watching session, that leaves no channel through which the fix can
799
+ * arrive: the user repairs a file the bundler does not track, nothing is
800
+ * invalidated, and the error stays on screen (samchon/ttsc#1312).
801
+ *
802
+ * The generation records the project walk even when the compile failed, so the
803
+ * files a fix would touch are exactly what it already holds. The cost is paid
804
+ * only on a failure, and only until the next compile succeeds and narrows the
805
+ * set back to the derived inputs.
457
806
  */
807
+ function notifyFailedGenerationInputs(hooks, cached) {
808
+ const addWatchFile = hooks?.addWatchFile;
809
+ if (addWatchFile === undefined) {
810
+ return;
811
+ }
812
+ for (const key of Object.keys(cached.inputHashes)) {
813
+ const input = path.resolve(cached.projectRoot, key);
814
+ if (isTransformScratchInput(input, cached.scratchDirectory)) {
815
+ continue;
816
+ }
817
+ // No evidence argument, deliberately. `missing: false` would be a claim
818
+ // this path cannot back: a failed generation is replayed for the rest of
819
+ // its pass without re-proving its inputs, so the walk that recorded them
820
+ // may be older than the delivery, and one of them having been deleted is a
821
+ // live reason for that compile to have failed. Letting the adapter probe
822
+ // also routes an absent input to the missing-input poll, which is the only
823
+ // channel through which restoring it can invalidate anything: a bundler
824
+ // watch on a path that does not exist registers nothing, and no module
825
+ // graph carries a type-only input. It costs one `existsSync` per input,
826
+ // and only where the adapter reads evidence at all, which is Vite serve.
827
+ addWatchFile(input);
828
+ }
829
+ }
458
830
  function notifyWatchInputs(hooks, cached, file) {
459
831
  const addWatchFile = hooks?.addWatchFile;
460
832
  if (addWatchFile === undefined) {
@@ -466,6 +838,7 @@ function notifyWatchInputs(hooks, cached, file) {
466
838
  file,
467
839
  projectRoot: cached.projectRoot,
468
840
  result: cached.result,
841
+ scratchDirectory: cached.scratchDirectory,
469
842
  temporaryTsconfig: cached.temporaryTsconfig,
470
843
  })) {
471
844
  // Hand the adapter the identity this generation already resolved and the
@@ -535,6 +908,7 @@ function deriveWatchInputs(state, props, fileIdentity) {
535
908
  const spelling = path.resolve(input);
536
909
  if (spelling === currentSpelling ||
537
910
  spelling === temporarySpelling ||
911
+ isTransformScratchInput(spelling, props.scratchDirectory) ||
538
912
  lexicalSeen.has(spelling)) {
539
913
  return;
540
914
  }
@@ -543,6 +917,8 @@ function deriveWatchInputs(state, props, fileIdentity) {
543
917
  output.push(input);
544
918
  };
545
919
  const appendPhysical = (input) => {
920
+ if (isTransformScratchInput(input, props.scratchDirectory))
921
+ return;
546
922
  const identity = derivationIdentity(state, input);
547
923
  if (excluded.has(identity) || physicalSeen.has(identity))
548
924
  return;
@@ -786,11 +1162,22 @@ function stripQuery(id) {
786
1162
  return query === -1 ? id : id.slice(0, query);
787
1163
  }
788
1164
  /**
789
- * Returns `true` for TypeScript declaration files (`.d.ts`, `.d.mts`,
790
- * `.d.cts`).
1165
+ * Returns `true` for every declaration-file spelling TypeScript-Go accepts.
1166
+ * Besides the standard `.d.ts`, `.d.mts`, and `.d.cts` forms, TypeScript-Go
1167
+ * treats an arbitrary-extension source such as `styles.d.css.ts` as a
1168
+ * declaration file too.
791
1169
  */
792
1170
  function isDeclarationFile(id) {
793
- return id.endsWith(".d.ts") || id.endsWith(".d.mts") || id.endsWith(".d.cts");
1171
+ // Module ids can cross process/platform boundaries (for example, a Windows
1172
+ // id inspected by a POSIX host). TypeScript-Go normalizes both separators
1173
+ // before taking the basename, so a `.d.` directory component must not turn
1174
+ // an ordinary source into a declaration file.
1175
+ const normalized = id.replaceAll("\\", "/");
1176
+ const base = normalized.slice(normalized.lastIndexOf("/") + 1);
1177
+ return (base.endsWith(".d.ts") ||
1178
+ base.endsWith(".d.mts") ||
1179
+ base.endsWith(".d.cts") ||
1180
+ (base.endsWith(".ts") && base.includes(".d.")));
794
1181
  }
795
1182
  /**
796
1183
  * Returns `true` when the caller has explicitly opted out of all plugins. An
@@ -818,25 +1205,44 @@ function createTransformResult(source, code) {
818
1205
  * state.
819
1206
  *
820
1207
  * Always compares the current module's in-memory source with the generation
821
- * snapshot. A cache whose owner called {@link beginTtscTransformBuild} can use
822
- * that comparison alone for a stable generation's first module delivery in the
823
- * current build. An incomplete generation may not take this shortcut: otherwise
824
- * a sibling output captured during a filesystem race could still be served
825
- * once. Later graph-bearing requests validate the file's derived input set and
826
- * project membership; graph-free envelopes conservatively re-hash the complete
827
- * project and out-of-walk snapshots. Any mismatch forces a complete
1208
+ * snapshot. A cache with a delivery epoch can use that comparison alone for a
1209
+ * stable generation's first delivery of each module in the current pass, once
1210
+ * the pass's own first delivery has proven the whole generation still matches
1211
+ * the filesystem. An incomplete generation may not take this shortcut:
1212
+ * otherwise a sibling output captured during a filesystem race could still be
1213
+ * served once. Later graph-bearing requests validate the file's derived input
1214
+ * set and project membership; graph-free envelopes conservatively re-hash the
1215
+ * complete project and out-of-walk snapshots. Any mismatch forces a complete
828
1216
  * re-transform.
829
1217
  */
830
- function matchesCachedSource(cached, file, source, buildScoped) {
1218
+ function matchesCachedSource(cached, file, source, epoch) {
831
1219
  const identities = envelopeDerivation(cached).identityContext;
832
1220
  const currentKey = toProjectKey(cached.projectRoot, file, identities);
833
- if (cached.inputHashes[currentKey] !== hashText(source)) {
1221
+ const identity = pathIdentityKey(file, identities);
1222
+ const expected = cached.sourceHashes?.[identity] ??
1223
+ cached.inputHashes[currentKey] ??
1224
+ cached.externalInputHashes?.[identity];
1225
+ if (expected !== hashText(source)) {
834
1226
  return false;
835
1227
  }
836
- if (buildScoped &&
837
- cached.projectSnapshotComplete === true &&
838
- !cached.servedFiles?.has(pathIdentityKey(file, identities))) {
839
- return true;
1228
+ if (epoch !== undefined && cached.projectSnapshotComplete === true) {
1229
+ if (cached.deliveryEpoch !== epoch) {
1230
+ // The pass's first delivery. The generation was settled against an
1231
+ // earlier pass, so prove the whole of it once — every input the envelope
1232
+ // declares, the directory membership, the universal host inputs, and the
1233
+ // out-of-walk snapshot — before any of this pass's deliveries may be
1234
+ // settled against it. That proof is what a per-pass recompile used to buy
1235
+ // (samchon/ttsc#1300), at a walk instead of a compile.
1236
+ if (!matchesCompleteInputSnapshot(cached, currentKey, source)) {
1237
+ return false;
1238
+ }
1239
+ cached.deliveryEpoch = epoch;
1240
+ cached.servedFiles?.clear();
1241
+ return true;
1242
+ }
1243
+ if (!cached.servedFiles?.has(identity)) {
1244
+ return true;
1245
+ }
840
1246
  }
841
1247
  if (cached.result.type !== "exception" &&
842
1248
  cached.result.graph !== undefined &&
@@ -886,6 +1292,7 @@ function matchesNarrowPersistentInputs(cached, file) {
886
1292
  file,
887
1293
  projectRoot: cached.projectRoot,
888
1294
  result: cached.result,
1295
+ scratchDirectory: cached.scratchDirectory,
889
1296
  temporaryTsconfig: cached.temporaryTsconfig,
890
1297
  });
891
1298
  for (const input of inputs) {
@@ -1104,6 +1511,7 @@ function isMissingPathError(error) {
1104
1511
  function captureUniversalHostInputValidation(cached, currentFile) {
1105
1512
  const filesystem = resultFilesystem(cached.result);
1106
1513
  const state = envelopeDerivation(cached);
1514
+ const failures = createGenerationProofFailures();
1107
1515
  const validation = {
1108
1516
  entries: new Map(),
1109
1517
  covered: new Set(),
@@ -1113,6 +1521,7 @@ function captureUniversalHostInputValidation(cached, currentFile) {
1113
1521
  filesystem,
1114
1522
  projectRoot: cached.projectRoot,
1115
1523
  result: cached.result,
1524
+ scratchDirectory: cached.scratchDirectory,
1116
1525
  temporaryTsconfig: cached.temporaryTsconfig,
1117
1526
  })) {
1118
1527
  const generationHashes = cached.result.type === "exception"
@@ -1128,8 +1537,14 @@ function captureUniversalHostInputValidation(cached, currentFile) {
1128
1537
  let readable = false;
1129
1538
  if (expected === undefined) {
1130
1539
  const current = path.resolve(currentFile);
1131
- if (path.resolve(input) !== current)
1132
- return undefined;
1540
+ if (path.resolve(input) !== current) {
1541
+ recordGenerationProofFailure(failures, {
1542
+ domain: "host",
1543
+ kind: "content-proof-missing",
1544
+ path: input,
1545
+ });
1546
+ return { failures };
1547
+ }
1133
1548
  // The current module may be supplied from an unsaved editor buffer. Its
1134
1549
  // generation snapshot is overlaid below from `currentSource`, so a disk
1135
1550
  // fingerprint would be both unavailable and the wrong authority. The
@@ -1139,7 +1554,12 @@ function captureUniversalHostInputValidation(cached, currentFile) {
1139
1554
  else {
1140
1555
  const current = hostInputStateHash(input, filesystem);
1141
1556
  if (expected !== current) {
1142
- return undefined;
1557
+ recordGenerationProofFailure(failures, {
1558
+ domain: "host",
1559
+ kind: "content-changed",
1560
+ path: input,
1561
+ });
1562
+ return { failures };
1143
1563
  }
1144
1564
  // A path both sides agree they could not read carries no bytes for a
1145
1565
  // signature to stand for. It still belongs in the manifest, so the
@@ -1150,16 +1570,35 @@ function captureUniversalHostInputValidation(cached, currentFile) {
1150
1570
  if (generationRealpaths !== undefined) {
1151
1571
  if (!Object.prototype.hasOwnProperty.call(generationRealpaths, absoluteInput) ||
1152
1572
  !sameHostInputRealpath(generationRealpaths[absoluteInput], hostInputRealpath(input, filesystem), state.identityContext)) {
1153
- return undefined;
1573
+ recordGenerationProofFailure(failures, {
1574
+ domain: "host",
1575
+ kind: Object.prototype.hasOwnProperty.call(generationRealpaths, absoluteInput)
1576
+ ? "realpath-changed"
1577
+ : "realpath-proof-missing",
1578
+ path: input,
1579
+ });
1580
+ return { failures };
1154
1581
  }
1155
1582
  }
1156
1583
  validation.covered.add(path.resolve(input));
1157
1584
  const before = inputMetadataEvidence(input, filesystem);
1158
- if (!matchesRecordedInput(cached, input))
1159
- return undefined;
1585
+ if (!matchesRecordedInput(cached, input)) {
1586
+ recordGenerationProofFailure(failures, {
1587
+ domain: "host",
1588
+ kind: "snapshot-mismatch",
1589
+ path: input,
1590
+ });
1591
+ return { failures };
1592
+ }
1160
1593
  const after = inputMetadataSignature(input, filesystem);
1161
- if (before?.signature !== after)
1162
- return undefined;
1594
+ if (before?.signature !== after) {
1595
+ recordGenerationProofFailure(failures, {
1596
+ domain: "host",
1597
+ kind: "changed-during-validation",
1598
+ path: input,
1599
+ });
1600
+ return { failures };
1601
+ }
1163
1602
  if (before !== undefined) {
1164
1603
  // Do not key this manifest by physical identity. A symlink/junction
1165
1604
  // spelling and its selected target deliberately share that identity,
@@ -1179,8 +1618,14 @@ function captureUniversalHostInputValidation(cached, currentFile) {
1179
1618
  const probe = missingPathProbe(input, filesystem);
1180
1619
  if (probe.blocker !== undefined) {
1181
1620
  const signature = inputMetadataSignature(probe.blocker, filesystem);
1182
- if (signature === undefined)
1183
- return undefined;
1621
+ if (signature === undefined) {
1622
+ recordGenerationProofFailure(failures, {
1623
+ domain: "host",
1624
+ kind: "blocker-metadata-unavailable",
1625
+ path: probe.blocker,
1626
+ });
1627
+ return { failures };
1628
+ }
1184
1629
  // A blocker proves a kind and an identity, not content: it is the
1185
1630
  // non-directory ancestor that makes everything below it unreachable, and
1186
1631
  // it cannot stop being that without its metadata moving. So it keeps a
@@ -1207,7 +1652,7 @@ function captureUniversalHostInputValidation(cached, currentFile) {
1207
1652
  names.add(normalizeHostInputName(probe.name, state.identityContext.caseSensitive(probe.directory)));
1208
1653
  }
1209
1654
  cached.hostInputValidation = validation;
1210
- return validation;
1655
+ return { failures, validation };
1211
1656
  }
1212
1657
  /**
1213
1658
  * The recorded state of an input the generation read nothing from: absent, or
@@ -1505,14 +1950,21 @@ function matchesCompleteInputSnapshot(cached, currentKey, source) {
1505
1950
  const declaredInputs = declaredProjectInputKeys(state, cached);
1506
1951
  const current = collectProjectInputSnapshot(cached.projectRoot, state.identityContext, resultFilesystem(cached.result), cached.inputSignatures === undefined
1507
1952
  ? undefined
1508
- : { hashes: cached.inputHashes, signatures: cached.inputSignatures });
1953
+ : { hashes: cached.inputHashes, signatures: cached.inputSignatures }, {
1954
+ // Judge membership by the rule the compile ran under, and read only the
1955
+ // inputs this comparison actually consults.
1956
+ declaredKeys: declaredInputs,
1957
+ policy: cached.membershipPolicy,
1958
+ });
1509
1959
  if (!walkSnapshotComplete(current, declaredInputs)) {
1510
1960
  return false;
1511
1961
  }
1512
1962
  if (!sameProjectDirectories(cached.projectDirectories, current.projectDirectories)) {
1513
1963
  return false;
1514
1964
  }
1515
- current.hashes[currentKey] = hashText(source);
1965
+ if (Object.prototype.hasOwnProperty.call(cached.inputHashes, currentKey)) {
1966
+ current.hashes[currentKey] = hashText(source);
1967
+ }
1516
1968
  if (!sameHashes(cached.inputHashes, current.hashes, declaredInputs)) {
1517
1969
  return false;
1518
1970
  }
@@ -1580,18 +2032,30 @@ function matchesExternalInputRealpaths(cached) {
1580
2032
  }
1581
2033
  /**
1582
2034
  * Capture external-input hashes without attaching post-compile state to an
1583
- * earlier graph. Graph members must carry compiler-time proof and still match
1584
- * it now; plugin-declared dependency-only paths retain the historical
1585
- * post-compile snapshot because their own protocol does not claim generation
1586
- * fingerprints.
2035
+ * earlier graph. Graph members and out-of-walk transformed sources must carry
2036
+ * compiler-time proof and still match it now; plugin-declared dependency-only
2037
+ * paths retain the historical post-compile snapshot because their own protocol
2038
+ * does not claim generation fingerprints.
1587
2039
  */
1588
2040
  function captureExternalInputSnapshot(cached, paths) {
1589
2041
  const state = envelopeDerivation(cached);
1590
2042
  const filesystem = resultFilesystem(cached.result);
1591
2043
  const graph = envelopeGraphIndexes(state, cached);
2044
+ // A non-declaration transform output is a compiler-realized source even when
2045
+ // a malformed or legacy graph omitted its node. Its output was computed from
2046
+ // compiler-time bytes, so a post-compile host read cannot prove coherence.
2047
+ const transformSources = new Set();
2048
+ if (cached.result.type === "success") {
2049
+ for (const output of Object.keys(cached.result.typescript)) {
2050
+ if (!isDeclarationFile(output)) {
2051
+ transformSources.add(derivationIdentity(state, path.resolve(cached.projectRoot, output)));
2052
+ }
2053
+ }
2054
+ }
1592
2055
  const hashes = {};
1593
2056
  const realpaths = {};
1594
2057
  const signatures = {};
2058
+ const failures = createGenerationProofFailures();
1595
2059
  let complete = true;
1596
2060
  // Sandwich every read between two metadata signatures. Only a signature that
1597
2061
  // survived its own read, and whose stamp's tick the filesystem's clock has
@@ -1609,21 +2073,47 @@ function captureExternalInputSnapshot(cached, paths) {
1609
2073
  // through to the recorded-state branch below, the same evidence a
1610
2074
  // plugin-declared dependency path carries. Its absence still invalidates
1611
2075
  // the generation when it appears, because `missing` is recorded state.
1612
- const speculativeOnly = graph.speculative.has(identity) &&
2076
+ const realizedTransformSource = transformSources.has(identity);
2077
+ const speculativeOnly = !realizedTransformSource &&
2078
+ graph.speculative.has(identity) &&
1613
2079
  !graph.inputProofs.has(identity) &&
1614
2080
  !graph.inputProofConflicts.has(identity);
1615
- if (graph.members.has(identity) && !speculativeOnly) {
2081
+ if ((realizedTransformSource || graph.members.has(identity)) &&
2082
+ !speculativeOnly) {
1616
2083
  const proof = graph.inputProofs.get(identity);
1617
2084
  if (proof === undefined || graph.inputProofConflicts.has(identity)) {
1618
2085
  complete = false;
2086
+ recordGenerationProofFailure(failures, {
2087
+ domain: "external",
2088
+ kind: graph.inputProofConflicts.has(identity)
2089
+ ? "graph-proof-conflict"
2090
+ : "graph-proof-missing",
2091
+ detail: graph.inputProofFailures.get(identity),
2092
+ path: input,
2093
+ });
1619
2094
  continue;
1620
2095
  }
1621
2096
  const before = inputMetadataEvidence(input, filesystem);
1622
2097
  const currentHash = graphInputStateHash(input, filesystem);
2098
+ const currentRealpath = hostInputRealpath(input, filesystem);
1623
2099
  const after = inputMetadataSignature(input, filesystem);
1624
- if (currentHash !== proof.hash ||
1625
- !sameHostInputRealpath(proof.realpath, hostInputRealpath(input, filesystem), state.identityContext)) {
2100
+ const realpathMatches = sameHostInputRealpath(proof.realpath, currentRealpath, state.identityContext);
2101
+ if (currentHash !== proof.hash || !realpathMatches) {
1626
2102
  complete = false;
2103
+ if (currentHash !== proof.hash) {
2104
+ recordGenerationProofFailure(failures, {
2105
+ domain: "external",
2106
+ kind: "graph-content-changed",
2107
+ path: input,
2108
+ });
2109
+ }
2110
+ if (!realpathMatches) {
2111
+ recordGenerationProofFailure(failures, {
2112
+ domain: "external",
2113
+ kind: "graph-realpath-changed",
2114
+ path: input,
2115
+ });
2116
+ }
1627
2117
  }
1628
2118
  else if (currentHash !== null) {
1629
2119
  // The recorded hash is the compiler's own proof, so a signature may
@@ -1643,27 +2133,30 @@ function captureExternalInputSnapshot(cached, paths) {
1643
2133
  if (hash !== null)
1644
2134
  record(input, before, after);
1645
2135
  }
1646
- return { complete, hashes, realpaths, signatures };
2136
+ return { complete, failures, hashes, realpaths, signatures };
1647
2137
  }
1648
- /** Verify every graph member still has the state read by the compiler. */
1649
- function matchesCompilerGraphInputProofs(cached) {
2138
+ /** Explain every graph member that no longer matches the compiler's state. */
2139
+ function compilerGraphInputProofFailures(cached) {
2140
+ const failures = createGenerationProofFailures();
1650
2141
  if (cached.result.type === "exception" ||
1651
2142
  cached.result.graph === undefined ||
1652
2143
  (cached.result.graph.inputHashes === undefined &&
1653
- cached.result.graph.inputRealpaths === undefined)) {
2144
+ cached.result.graph.inputRealpaths === undefined &&
2145
+ cached.result.graph.inputProofFailures === undefined)) {
1654
2146
  // Legacy sidecars remain compatible for ordinary in-project graphs. Their
1655
2147
  // out-of-walk members are still rejected by captureExternalInputSnapshot,
1656
2148
  // where a post-compile snapshot cannot prove the compiler's generation.
1657
- return true;
2149
+ return failures;
1658
2150
  }
1659
2151
  const state = envelopeDerivation(cached);
1660
2152
  const filesystem = resultFilesystem(cached.result);
1661
2153
  const graph = envelopeGraphIndexes(state, cached);
1662
- if (graph.inputProofConflicts.size !== 0) {
1663
- return false;
1664
- }
1665
2154
  for (const identity of graph.members) {
1666
2155
  const proof = graph.inputProofs.get(identity);
2156
+ const spelling = proof?.path ?? graph.spellings.get(identity) ?? cached.projectRoot;
2157
+ if (isTransformScratchInput(spelling, cached.scratchDirectory)) {
2158
+ continue;
2159
+ }
1667
2160
  // A speculative candidate has no compile-time read to prove. Requiring one
1668
2161
  // would void every generation of every project whose resolution passes over
1669
2162
  // a higher-priority spelling, which is every project with a dependency
@@ -1672,13 +2165,41 @@ function matchesCompilerGraphInputProofs(cached) {
1672
2165
  if (proof === undefined && graph.speculative.has(identity)) {
1673
2166
  continue;
1674
2167
  }
1675
- if (proof === undefined ||
1676
- graphInputStateHash(proof.path, filesystem) !== proof.hash ||
1677
- !sameHostInputRealpath(proof.realpath, hostInputRealpath(proof.path, filesystem), state.identityContext)) {
1678
- return false;
2168
+ if (graph.inputProofConflicts.has(identity)) {
2169
+ recordGenerationProofFailure(failures, {
2170
+ domain: "graph",
2171
+ kind: "proof-conflict",
2172
+ detail: graph.inputProofFailures.get(identity),
2173
+ path: spelling,
2174
+ });
2175
+ continue;
2176
+ }
2177
+ if (proof === undefined) {
2178
+ recordGenerationProofFailure(failures, {
2179
+ domain: "graph",
2180
+ kind: "proof-missing",
2181
+ detail: graph.inputProofFailures.get(identity),
2182
+ path: spelling,
2183
+ });
2184
+ continue;
2185
+ }
2186
+ const currentHash = graphInputStateHash(proof.path, filesystem);
2187
+ if (currentHash !== proof.hash) {
2188
+ recordGenerationProofFailure(failures, {
2189
+ domain: "graph",
2190
+ kind: "content-changed",
2191
+ path: proof.path,
2192
+ });
2193
+ }
2194
+ if (!sameHostInputRealpath(proof.realpath, hostInputRealpath(proof.path, filesystem), state.identityContext)) {
2195
+ recordGenerationProofFailure(failures, {
2196
+ domain: "graph",
2197
+ kind: "realpath-changed",
2198
+ path: proof.path,
2199
+ });
1679
2200
  }
1680
2201
  }
1681
- return true;
2202
+ return failures;
1682
2203
  }
1683
2204
  /** Compare one derived input with the snapshot that owned it at generation. */
1684
2205
  function matchesRecordedInput(cached, input) {
@@ -1725,23 +2246,36 @@ function markCachedSourceServed(cached, file) {
1725
2246
  * slash path. Exported so hosts without a per-build boundary (`@ttsc/metro`)
1726
2247
  * can fold the identical input universe into their own cache fingerprints.
1727
2248
  */
1728
- function collectProjectInputHashes(projectRoot, identities = createHostPathIdentityContext(), filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1729
- return collectProjectInputSnapshot(projectRoot, identities, filesystem)
1730
- .hashes;
2249
+ function collectProjectInputHashes(projectRoot, identities = createHostPathIdentityContext(), filesystem = DEFAULT_FILESYSTEM_OPERATIONS, policy) {
2250
+ return collectProjectInputSnapshot(projectRoot, identities, filesystem, undefined, {
2251
+ policy,
2252
+ }).hashes;
1731
2253
  }
1732
2254
  /** Hash project files and snapshot the directory topology in one walk. */
1733
- function collectProjectInputSnapshot(projectRoot, identities, filesystem = DEFAULT_FILESYSTEM_OPERATIONS, proven) {
2255
+ function collectProjectInputSnapshot(projectRoot, identities, filesystem = DEFAULT_FILESYSTEM_OPERATIONS, proven, options) {
1734
2256
  const hashes = {};
1735
2257
  const fileSignatures = {};
1736
2258
  const provenSignatures = {};
1737
2259
  const unstableFiles = new Set();
1738
2260
  let attributed = true;
1739
- const walked = walkProjectInputs(projectRoot, filesystem);
2261
+ const walked = walkProjectInputs(projectRoot, filesystem, options?.policy);
2262
+ const walkFailures = [...walked.failures];
1740
2263
  let complete = walked.complete;
1741
2264
  for (const file of walked.files) {
1742
2265
  try {
1743
- const before = inputMetadataEvidence(file, filesystem);
1744
2266
  const key = toProjectKey(projectRoot, file, identities);
2267
+ // A caller validating a generation compares hashes over that
2268
+ // generation's declared inputs alone (`sameHashes` takes the declared key
2269
+ // set), so reading anything else is work whose result is never consulted.
2270
+ // Skipping it is what keeps a directory full of emitted files from
2271
+ // costing a read per file on the pass that first sees them
2272
+ // (samchon/ttsc#1307). Capture passes supply no restriction and still
2273
+ // record the whole walk.
2274
+ if (options?.declaredKeys !== undefined &&
2275
+ !options.declaredKeys.has(key)) {
2276
+ continue;
2277
+ }
2278
+ const before = inputMetadataEvidence(file, filesystem);
1745
2279
  // A file whose signature still equals the one captured around the read
1746
2280
  // that produced the recorded hash carries that content, so the whole
1747
2281
  // project does not have to be re-read to prove one delivery. A signature
@@ -1764,6 +2298,7 @@ function collectProjectInputSnapshot(projectRoot, identities, filesystem = DEFAU
1764
2298
  before.signature !== after) {
1765
2299
  complete = false;
1766
2300
  unstableFiles.add(key);
2301
+ walkFailures.push({ kind: "file-changed-during-read", path: file });
1767
2302
  }
1768
2303
  else {
1769
2304
  fileSignatures[key] = after;
@@ -1780,6 +2315,7 @@ function collectProjectInputSnapshot(projectRoot, identities, filesystem = DEFAU
1780
2315
  // File watchers may observe a transform while another process is moving
1781
2316
  // or deleting files. The missing key invalidates older cache entries.
1782
2317
  complete = false;
2318
+ walkFailures.push({ kind: "file-read-failed", path: file });
1783
2319
  try {
1784
2320
  unstableFiles.add(toProjectKey(projectRoot, file, identities));
1785
2321
  }
@@ -1798,26 +2334,36 @@ function collectProjectInputSnapshot(projectRoot, identities, filesystem = DEFAU
1798
2334
  projectDirectories: walked.directories,
1799
2335
  provenSignatures,
1800
2336
  unstableFiles,
2337
+ walkFailures,
1801
2338
  };
1802
2339
  }
1803
2340
  /**
1804
- * Enumerate every regular file under `root`, skipping well-known output and
1805
- * tooling directories (see {@link isIgnoredProjectDirectory}).
2341
+ * Enumerate every regular file under `root`, skipping the directories no
2342
+ * configuration can name ({@link isIgnoredProjectDirectory}) and the ones the
2343
+ * resolved configuration excludes ({@link isExcludedProjectDirectory}).
1806
2344
  *
1807
2345
  * Uses an iterative DFS instead of `fs.readdirSync` recursion to avoid
1808
2346
  * unbounded call-stack depth on deep project trees. The result is sorted so
1809
2347
  * that hash comparisons are deterministic across OS-level directory orderings.
1810
2348
  */
1811
- function walkProjectInputs(root, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
2349
+ function walkProjectInputs(root, filesystem = DEFAULT_FILESYSTEM_OPERATIONS, policy = tsconfigPaths.PERMISSIVE_PROJECT_MEMBERSHIP_POLICY) {
1812
2350
  let complete = true;
1813
- const directories = [];
2351
+ const failures = [];
1814
2352
  const files = [];
2353
+ // Collected in one pass, then digested in a second. A directory's digest has
2354
+ // to know whether each child directory can hold program inputs, and the walk
2355
+ // learns that only after descending, so the two cannot be one pass.
2356
+ const visited = [];
1815
2357
  const stack = [root];
1816
2358
  while (stack.length !== 0) {
1817
2359
  const current = stack.pop();
1818
2360
  const before = projectDirectorySignature(current, filesystem);
1819
2361
  if (before === undefined) {
1820
2362
  complete = false;
2363
+ failures.push({
2364
+ kind: "directory-metadata-unavailable",
2365
+ path: current,
2366
+ });
1821
2367
  continue;
1822
2368
  }
1823
2369
  let entries;
@@ -1826,39 +2372,112 @@ function walkProjectInputs(root, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1826
2372
  }
1827
2373
  catch {
1828
2374
  complete = false;
2375
+ failures.push({ kind: "directory-read-failed", path: current });
1829
2376
  continue;
1830
2377
  }
1831
2378
  const after = projectDirectorySignature(current, filesystem);
1832
2379
  if (after === undefined || before !== after) {
1833
2380
  complete = false;
2381
+ failures.push({
2382
+ kind: after === undefined
2383
+ ? "directory-metadata-unavailable"
2384
+ : "directory-changed-during-walk",
2385
+ path: current,
2386
+ });
1834
2387
  }
1835
- directories.push({
2388
+ const visit = {
2389
+ childDirectories: [],
2390
+ entries: [],
2391
+ ownInput: false,
1836
2392
  path: current,
1837
2393
  // If membership moved during enumeration, force the next delivery to
1838
2394
  // replace this generation instead of blessing a torn directory/file
1839
2395
  // snapshot as stable.
1840
- signature: after !== undefined && before === after
1841
- ? after
2396
+ stable: after !== undefined && before === after
2397
+ ? undefined
1842
2398
  : `unstable:${before}:${after ?? "missing"}`,
1843
- });
2399
+ };
1844
2400
  for (const entry of entries) {
1845
2401
  if (isIgnoredProjectDirectory(entry.name)) {
1846
2402
  continue;
1847
2403
  }
1848
2404
  const file = path.join(current, entry.name);
2405
+ if (entry.isDirectory() && isExcludedProjectDirectory(file, policy)) {
2406
+ continue;
2407
+ }
2408
+ const possible = isPossibleProgramEntry(entry, policy);
2409
+ visit.entries.push({
2410
+ kind: [
2411
+ entry.isDirectory(),
2412
+ entry.isFile(),
2413
+ entry.isSymbolicLink(),
2414
+ ].join(":"),
2415
+ name: entry.name,
2416
+ possible,
2417
+ });
1849
2418
  if (entry.isDirectory()) {
2419
+ visit.childDirectories.push(file);
1850
2420
  stack.push(file);
1851
2421
  }
1852
- else if (entry.isFile()) {
2422
+ else if (entry.isFile() && possible) {
2423
+ // Only a file that could enter the program is hashed. A file that
2424
+ // could not is either irrelevant to every generation, or it is one the
2425
+ // compiler actually read, in which case the graph reports it and
2426
+ // `isProjectWalkPath` now agrees it is out of the walk, so it is
2427
+ // recorded and proven by the out-of-walk snapshot instead. Hashing an
2428
+ // emitted tree here bought nothing and cost a read per file, including
2429
+ // in `@ttsc/metro`, whose fingerprint re-keys every transformed file
2430
+ // (samchon/ttsc#1307).
1853
2431
  files.push(file);
2432
+ visit.ownInput = true;
1854
2433
  }
1855
2434
  }
2435
+ visited.push(visit);
2436
+ }
2437
+ // A directory matters to program membership only if its subtree can hold a
2438
+ // program input. Propagate that up from the directories that hold one, so a
2439
+ // bundler creating `out/` and filling it with JavaScript a project admitting
2440
+ // none can never compile is not a membership change at any level: not in the
2441
+ // directory itself, and not in the parent that now lists it
2442
+ // (samchon/ttsc#1307).
2443
+ const byPath = new Map(visited.map((visit) => [visit.path, visit]));
2444
+ const relevant = new Set();
2445
+ for (const visit of visited) {
2446
+ if (!visit.ownInput) {
2447
+ continue;
2448
+ }
2449
+ let current = visit.path;
2450
+ while (current !== undefined && !relevant.has(current)) {
2451
+ relevant.add(current);
2452
+ const parent = path.dirname(current);
2453
+ current = parent === current || !byPath.has(parent) ? undefined : parent;
2454
+ }
1856
2455
  }
2456
+ const directories = visited.map((visit) => {
2457
+ const membership = visit.entries
2458
+ .filter((entry) => entry.possible &&
2459
+ (!visit.childDirectories.includes(path.join(visit.path, entry.name)) ||
2460
+ relevant.has(path.join(visit.path, entry.name))))
2461
+ .map((entry) => `${entry.name}:${entry.kind}`);
2462
+ return {
2463
+ path: visit.path,
2464
+ relevant: relevant.has(visit.path),
2465
+ signature: visit.stable ??
2466
+ hashText(membership.sort().join(String.fromCharCode(0))),
2467
+ };
2468
+ });
1857
2469
  directories.sort((left, right) => left.path.localeCompare(right.path));
1858
2470
  files.sort();
1859
- return { complete, directories, files };
2471
+ return { complete, directories, failures, files };
1860
2472
  }
1861
- /** Return a cheap identity for one directory's immediate membership. */
2473
+ /**
2474
+ * Return a directory's metadata stamp, used to detect that its membership moved
2475
+ * _while_ the walk was enumerating it, and to feed the observed-clock floor.
2476
+ *
2477
+ * This is the right instrument for that job and the wrong one for comparing two
2478
+ * generations: it moves for ignored entries too. {@link walkProjectInputs}
2479
+ * records the filtered membership digest for the comparison instead.
2480
+ */
1862
2481
  function projectDirectorySignature(directory, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1863
2482
  try {
1864
2483
  const stats = filesystem.statBigInt(directory);
@@ -1883,9 +2502,24 @@ function projectDirectorySignature(directory, filesystem = DEFAULT_FILESYSTEM_OP
1883
2502
  }
1884
2503
  /** Compare two deterministic project-directory membership snapshots. */
1885
2504
  function sameProjectDirectories(left, right) {
1886
- return (left.length === right.length &&
1887
- left.every((directory, index) => directory.path === right[index]?.path &&
1888
- directory.signature === right[index]?.signature));
2505
+ // Compare only the directories that can hold program inputs, on either side.
2506
+ // A directory irrelevant on both is not part of the program's membership at
2507
+ // all, so its appearance, disappearance or churn says nothing: that is a
2508
+ // bundler's output tree. One that gained or lost relevance is present in the
2509
+ // comparison from the side where it counts, and so is caught.
2510
+ const select = (snapshots) => new Map(snapshots
2511
+ .filter((directory) => directory.relevant)
2512
+ .map((directory) => [directory.path, directory]));
2513
+ const leftRelevant = select(left);
2514
+ const rightRelevant = select(right);
2515
+ const paths = new Set([...leftRelevant.keys(), ...rightRelevant.keys()]);
2516
+ for (const location of paths) {
2517
+ if (leftRelevant.get(location)?.signature !==
2518
+ rightRelevant.get(location)?.signature) {
2519
+ return false;
2520
+ }
2521
+ }
2522
+ return true;
1889
2523
  }
1890
2524
  /**
1891
2525
  * Open one directory's change notification through the cache-owned watch seam,
@@ -1902,14 +2536,16 @@ function openDirectoryWatch(filesystem, directory, listener, onError) {
1902
2536
  return { close: () => watcher.close() };
1903
2537
  }
1904
2538
  /** Watch every walked directory for membership changes after generation. */
1905
- async function createProjectMutationTracker(directories, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
2539
+ async function createProjectMutationTracker(directories, filesystem = DEFAULT_FILESYSTEM_OPERATIONS, policy = tsconfigPaths.PERMISSIVE_PROJECT_MEMBERSHIP_POLICY) {
1906
2540
  const tracker = {
2541
+ changes: new Set(),
2542
+ changesOmitted: false,
1907
2543
  close: () => undefined,
1908
2544
  failed: false,
1909
2545
  membershipChanged: false,
1910
2546
  };
1911
2547
  if (process.platform === "win32" && filesystem.watch === undefined) {
1912
- await registerWindowsProjectMutationTracker(tracker, directories.map((directory) => ({ directory: directory.path })), false, filesystem);
2548
+ await registerWindowsProjectMutationTracker(tracker, directories.map((directory) => ({ directory: directory.path })), false, filesystem, (location, filename) => reportsProgramMembership(path.join(location, filename), filename, policy, filesystem));
1913
2549
  return tracker;
1914
2550
  }
1915
2551
  const watchers = [];
@@ -1920,9 +2556,17 @@ async function createProjectMutationTracker(directories, filesystem = DEFAULT_FI
1920
2556
  };
1921
2557
  for (const directory of directories) {
1922
2558
  try {
1923
- watchers.push(openDirectoryWatch(filesystem, directory.path, (eventType) => {
1924
- if (eventType === "rename")
1925
- tracker.membershipChanged = true;
2559
+ watchers.push(openDirectoryWatch(filesystem, directory.path, (eventType, filename) => {
2560
+ if (eventType !== "rename") {
2561
+ return;
2562
+ }
2563
+ if (filename !== null &&
2564
+ !reportsProgramMembership(path.join(directory.path, filename), filename, policy, filesystem)) {
2565
+ return;
2566
+ }
2567
+ recordProjectMutation(tracker, filename === null
2568
+ ? directory.path
2569
+ : path.join(directory.path, filename));
1926
2570
  }, () => {
1927
2571
  tracker.failed = true;
1928
2572
  }));
@@ -1958,6 +2602,8 @@ async function createHostInputMutationTracker(inputs, filesystem, covered, event
1958
2602
  names: [...location.names],
1959
2603
  }));
1960
2604
  const tracker = {
2605
+ changes: new Set(),
2606
+ changesOmitted: false,
1961
2607
  close: () => undefined,
1962
2608
  // Coverage is the caller's claim, and it is required rather than derived
1963
2609
  // from the input list: an input is watched by its exact name here, but only
@@ -1991,7 +2637,9 @@ async function createHostInputMutationTracker(inputs, filesystem, covered, event
1991
2637
  ? null
1992
2638
  : normalizeHostInputName(filename, caseSensitive);
1993
2639
  if (reported === null || names.has(reported)) {
1994
- tracker.membershipChanged = true;
2640
+ recordProjectMutation(tracker, filename === null
2641
+ ? location.directory
2642
+ : path.join(location.directory, filename));
1995
2643
  }
1996
2644
  }, () => {
1997
2645
  tracker.failed = true;
@@ -2003,6 +2651,92 @@ async function createHostInputMutationTracker(inputs, filesystem, covered, event
2003
2651
  }
2004
2652
  return tracker;
2005
2653
  }
2654
+ /**
2655
+ * Whether a path lies inside a directory the configuration excludes.
2656
+ *
2657
+ * Lexical, exactly like the walk and like {@link isProjectWalkPath}, and for the
2658
+ * reason that predicate states: walk membership is lexical, so resolving a path
2659
+ * to physical identity first would collapse two spellings the walk keeps apart
2660
+ * and claim it covered a subtree it never followed. A junction whose target the
2661
+ * walk hashes under its own name is exactly that, and canonicalizing here would
2662
+ * suppress every event in it.
2663
+ *
2664
+ * `strictly` excludes an exact match, for the case where the excluded entry
2665
+ * names a file rather than a directory: `exclude` accepts one, the walk applies
2666
+ * exclusion to directories alone, so that file is still hashed and its events
2667
+ * must keep counting.
2668
+ */
2669
+ function insideExcludedProjectDirectory(location, policy, strictly) {
2670
+ if (policy.excludedDirectories.length === 0) {
2671
+ return false;
2672
+ }
2673
+ const resolved = path.resolve(location);
2674
+ return policy.excludedDirectories.some((excluded) => {
2675
+ const target = path.resolve(excluded);
2676
+ if (strictly && target === resolved) {
2677
+ return false;
2678
+ }
2679
+ return pathIsWithin(resolved, target);
2680
+ });
2681
+ }
2682
+ /**
2683
+ * Whether one directory event can be a change to the program's membership.
2684
+ *
2685
+ * The live tracker has to answer the same question the membership digest does,
2686
+ * or the two disagree about the same project: a bundler writing content-hashed
2687
+ * output fires a rename per rebuild, and treating that as membership kept the
2688
+ * cost samchon/ttsc#1307 removes on every host that has no build boundary,
2689
+ * which is every host the narrow path exists for.
2690
+ *
2691
+ * A name that could be a program input counts, unless it sits under a directory
2692
+ * the walk never descends into. A name that could not still counts when the
2693
+ * path is now a directory, because the walk's watches were opened for the
2694
+ * directories that existed when the generation was captured, so a directory
2695
+ * created since is not watched and the sources that may appear in it would
2696
+ * otherwise be invisible. A directory the configuration excludes is the
2697
+ * exception: the walk cannot see inside it, so the tracker must not either, or
2698
+ * emptying and recreating an `outDir` costs a compile per build. An event whose
2699
+ * name the host did not report is unattributable and always counts.
2700
+ */
2701
+ function reportsProgramMembership(location, filename, policy, filesystem) {
2702
+ if (isPossibleProgramFileName(filename, policy)) {
2703
+ // A name the program could admit. It still says nothing if it lies inside a
2704
+ // directory the walk never descends into, because the digest cannot see
2705
+ // there either and the tracker must not be the one side that reacts.
2706
+ return !insideExcludedProjectDirectory(location, policy, true);
2707
+ }
2708
+ let directory;
2709
+ try {
2710
+ directory = filesystem.lstat(location).isDirectory();
2711
+ }
2712
+ catch {
2713
+ // Gone again, or unreadable. Its name could not have been a program input,
2714
+ // and a directory removed under this one reports its own contents leaving
2715
+ // through the watch that was opened on it.
2716
+ return false;
2717
+ }
2718
+ if (!directory) {
2719
+ return false;
2720
+ }
2721
+ // A directory counts, because it can hold sources and the tracker is not
2722
+ // watching it yet, unless the configuration says the program does not contain
2723
+ // it. Emptying and recreating an `outDir`, which is what `emptyOutDir` and
2724
+ // `output.clean` do on every build, would otherwise void the generation once
2725
+ // per build on every host that has no build boundary.
2726
+ return !insideExcludedProjectDirectory(location, policy, false);
2727
+ }
2728
+ /** Record enough exact mutation evidence without retaining an event stream. */
2729
+ function recordProjectMutation(tracker, changed) {
2730
+ tracker.membershipChanged = true;
2731
+ if (tracker.changes.has(changed))
2732
+ return;
2733
+ if (tracker.changes.size < MAX_GENERATION_MUTATION_PATHS) {
2734
+ tracker.changes.add(changed);
2735
+ }
2736
+ else {
2737
+ tracker.changesOmitted = true;
2738
+ }
2739
+ }
2006
2740
  let windowsProjectMutationBroker;
2007
2741
  /**
2008
2742
  * Register directory watches in an isolated Windows process.
@@ -2011,8 +2745,21 @@ let windowsProjectMutationBroker;
2011
2745
  * temporary tree is deleted. Isolation turns that unrecoverable process abort
2012
2746
  * into an ordinary broker exit and a conservative cache miss in the host.
2013
2747
  */
2014
- async function registerWindowsProjectMutationTracker(tracker, locations, allEvents, filesystem) {
2748
+ async function registerWindowsProjectMutationTracker(tracker, locations, allEvents, filesystem,
2749
+ /**
2750
+ * Optional filter for the project-directory tracker, whose events have to be
2751
+ * narrowed to program membership exactly as the in-process watcher's are. The
2752
+ * name-watching trackers pass none, since they already watch exact names.
2753
+ */
2754
+ membership) {
2015
2755
  const broker = getWindowsProjectMutationBroker();
2756
+ // The child watches canonical directories, and reports its events under that
2757
+ // spelling. Everything else in the adapter speaks the walk's own spelling,
2758
+ // which on Windows can be an 8.3 short form of the same directory, so keep
2759
+ // the way back: a filter that compared the child's spelling against the
2760
+ // configuration's would be comparing two names for one directory that share
2761
+ // no common prefix (samchon/ttsc#1307).
2762
+ const spellings = new Map();
2016
2763
  const normalized = locations.map((location) => {
2017
2764
  let directory;
2018
2765
  try {
@@ -2021,6 +2768,7 @@ async function registerWindowsProjectMutationTracker(tracker, locations, allEven
2021
2768
  catch {
2022
2769
  directory = path.resolve(location.directory);
2023
2770
  }
2771
+ spellings.set(directory, location.directory);
2024
2772
  return {
2025
2773
  directory,
2026
2774
  ...(location.names === undefined ? {} : { names: location.names }),
@@ -2034,7 +2782,12 @@ async function registerWindowsProjectMutationTracker(tracker, locations, allEven
2034
2782
  const ready = new Promise((resolve) => {
2035
2783
  resolveReady = resolve;
2036
2784
  });
2037
- broker.trackers.set(id, { ready: resolveReady, tracker });
2785
+ broker.trackers.set(id, {
2786
+ membership,
2787
+ ready: resolveReady,
2788
+ spellings,
2789
+ tracker,
2790
+ });
2038
2791
  tracker.drain = () => drainWindowsProjectMutationBroker(broker);
2039
2792
  tracker.close = () => {
2040
2793
  const active = broker.trackers.get(id);
@@ -2128,7 +2881,22 @@ function getWindowsProjectMutationBroker() {
2128
2881
  if (record.ready === true)
2129
2882
  registration.ready();
2130
2883
  if (record.ready !== true && record.failed !== true) {
2131
- registration.tracker.membershipChanged = true;
2884
+ if (typeof record.directory === "string") {
2885
+ // The walk's spelling for this directory, which is what every
2886
+ // comparison and every recorded witness downstream expects.
2887
+ const reported = registration.spellings.get(record.directory) ?? record.directory;
2888
+ if (typeof record.filename === "string" &&
2889
+ registration.membership !== undefined &&
2890
+ !registration.membership(reported, record.filename)) {
2891
+ return;
2892
+ }
2893
+ recordProjectMutation(registration.tracker, typeof record.filename === "string"
2894
+ ? path.join(reported, record.filename)
2895
+ : reported);
2896
+ }
2897
+ else {
2898
+ registration.tracker.membershipChanged = true;
2899
+ }
2132
2900
  }
2133
2901
  });
2134
2902
  windowsProjectMutationBroker = broker;
@@ -2212,7 +2980,7 @@ const WINDOWS_WATCH_BROKER_SOURCE = [
2212
2980
  " const names = location.names === undefined ? undefined : new Set(location.names.map((name) => name.toLowerCase()));",
2213
2981
  " const watcher = fs.watch(location.directory, { persistent: false }, (event, filename) => {",
2214
2982
  " const matches = names === undefined || filename === null || names.has(String(filename).toLowerCase());",
2215
- ' if (matches && (message.allEvents || event === "rename")) process.send?.({ id: message.id });',
2983
+ ' if (matches && (message.allEvents || event === "rename")) process.send?.({ directory: location.directory, filename: filename === null ? null : String(filename), id: message.id });',
2216
2984
  " });",
2217
2985
  ' watcher.on("error", () => process.send?.({ failed: true, id: message.id }));',
2218
2986
  " watchers.push(watcher);",
@@ -2303,7 +3071,7 @@ async function settleProjectMutationEvents(cached) {
2303
3071
  * Missing paths and files reached through symlinks or Windows junctions are
2304
3072
  * out-of-walk inputs that only the reference graph can prove relevant.
2305
3073
  */
2306
- function isProjectWalkPath(root, file, _identities = createHostPathIdentityContext(), filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
3074
+ function isProjectWalkPath(root, file, _identities = createHostPathIdentityContext(), filesystem = DEFAULT_FILESYSTEM_OPERATIONS, policy = tsconfigPaths.PERMISSIVE_PROJECT_MEMBERSHIP_POLICY) {
2307
3075
  // Walk membership is lexical. Resolving `file` to physical identity first
2308
3076
  // would turn `root/alias/value.ts` into `root/target/value.ts`, hide the
2309
3077
  // symlink segment from the lstat loop below, and falsely claim the project
@@ -2317,7 +3085,20 @@ function isProjectWalkPath(root, file, _identities = createHostPathIdentityConte
2317
3085
  return false;
2318
3086
  }
2319
3087
  const segments = relative.split(path.sep);
2320
- if (segments.some(isIgnoredProjectDirectory)) {
3088
+ // The last segment is the file itself, which the walk names rather than
3089
+ // descends into, so only the directory components decide walk membership.
3090
+ if (segments.slice(0, -1).some(isIgnoredProjectDirectory)) {
3091
+ return false;
3092
+ }
3093
+ if (isExcludedProjectDirectory(path.dirname(path.resolve(file)), policy)) {
3094
+ return false;
3095
+ }
3096
+ // The walk hashes only files that could enter the program, so a path it does
3097
+ // not hash is out of the walk by definition. Answering otherwise would leave
3098
+ // a graph input the compiler really read in neither snapshot: absent from
3099
+ // `inputHashes` because the walk skipped it, and absent from the out-of-walk
3100
+ // snapshot because this predicate claimed the walk covered it.
3101
+ if (!isPossibleProgramFileName(path.basename(file), policy)) {
2321
3102
  return false;
2322
3103
  }
2323
3104
  let current = resolvedRoot;
@@ -2419,12 +3200,13 @@ function matchesCachedExternalInputs(cached) {
2419
3200
  }
2420
3201
  /**
2421
3202
  * Derive the absolute out-of-walk input set of a whole project transform: the
2422
- * union of every reference-graph member (edge keys and targets, globals, the
2423
- * config chain) and every plugin-reported dependency, minus everything the
2424
- * project walk already hashes and the disposed temp-dir tsconfig. These are the
2425
- * inputs {@link matchesCachedSource}'s walk cannot see. Resolution candidates
2426
- * that are still missing remain in this set even under the project root: the
2427
- * first walk cannot hash a file that has not been created yet.
3203
+ * union of every transformed source key, reference-graph member (edge keys and
3204
+ * targets, globals, the config chain), and plugin-reported dependency, minus
3205
+ * everything the project walk already hashes and the disposed transform scratch
3206
+ * tree. These are the inputs {@link matchesCachedSource}'s walk cannot see.
3207
+ * Resolution candidates that are still missing remain in this set even under
3208
+ * the project root: the first walk cannot hash a file that has not been created
3209
+ * yet.
2428
3210
  *
2429
3211
  * A `dependenciesComplete` declaration deliberately does not narrow the stored
2430
3212
  * set: other files in the same whole-project result can still own the omitted
@@ -2441,6 +3223,10 @@ function selectExternalInputPaths(props) {
2441
3223
  const identities = createHostPathIdentityContext(filesystem);
2442
3224
  const resolutionCandidates = new Set();
2443
3225
  const graph = props.result.graph;
3226
+ // Every transform output key names the source file whose transformed text it
3227
+ // carries. Keep an out-of-walk source in the external snapshot instead of
3228
+ // injecting it into the project-walk key universe (samchon/ttsc#252).
3229
+ members.push(...Object.keys(props.result.typescript));
2444
3230
  if (graph !== undefined) {
2445
3231
  for (const [source, targets] of Object.entries(graph.edges ?? {})) {
2446
3232
  members.push(source);
@@ -2497,9 +3283,10 @@ function selectExternalInputPaths(props) {
2497
3283
  const identity = pathIdentityKey(absolute, identities);
2498
3284
  const missingCandidate = resolutionCandidates.has(identity) && !filesystem.exists(absolute);
2499
3285
  if (identity === excluded ||
3286
+ isTransformScratchInput(absolute, props.scratchDirectory) ||
2500
3287
  seen.has(spelling) ||
2501
3288
  (!missingCandidate &&
2502
- isProjectWalkPath(props.projectRoot, absolute, identities, filesystem))) {
3289
+ isProjectWalkPath(props.projectRoot, absolute, identities, filesystem, props.membershipPolicy))) {
2503
3290
  continue;
2504
3291
  }
2505
3292
  // Preserve distinct lexical aliases even when they currently select the
@@ -2560,6 +3347,7 @@ function selectNotifiableAbsentInputs(props) {
2560
3347
  const absolute = path.resolve(props.projectRoot, candidate);
2561
3348
  const spelling = path.resolve(absolute);
2562
3349
  if (seen.has(spelling) ||
3350
+ isTransformScratchInput(absolute, props.scratchDirectory) ||
2563
3351
  (excluded !== undefined &&
2564
3352
  pathIdentityKey(absolute, identities) === excluded) ||
2565
3353
  props.filesystem.exists(absolute)) {
@@ -2661,21 +3449,44 @@ function insideProject(directory, projectRoot) {
2661
3449
  */
2662
3450
  const NOTIFIABLE_ABSENCE_DIRECTORY_LIMIT = 512;
2663
3451
  function isIgnoredProjectDirectory(name) {
2664
- return (name === ".git" ||
2665
- name === ".ttsc" ||
2666
- name === ".cache" ||
2667
- name === ".next" ||
2668
- name === ".nuxt" ||
2669
- name === ".svelte-kit" ||
2670
- name === ".turbo" ||
2671
- name === ".vite" ||
2672
- name === "build" ||
2673
- name === "coverage" ||
2674
- name === "dist" ||
2675
- name === "node_modules" ||
2676
- name === "out" ||
2677
- name === "temp" ||
2678
- name === "tmp");
3452
+ // The residue of what used to be a fifteen-name list, kept to the three
3453
+ // directories no tsconfig can name and no program can contain: the VCS
3454
+ // store, the package manager's tree (TypeScript's own default `exclude`
3455
+ // carries it too), and ttsc's own plugin cache. Everything else the old list
3456
+ // guessed at, and guessing was wrong in both directions: a bundler writing
3457
+ // to an unnamed directory changed project membership with its own output,
3458
+ // while a real source directory named `build` or `temp` was dropped from the
3459
+ // walk and its new files were never seen (samchon/ttsc#1307). Those are now
3460
+ // decided by `ITtscProjectMembershipPolicy`, which reads the configuration
3461
+ // that actually knows.
3462
+ return name === ".git" || name === ".ttsc" || name === "node_modules";
3463
+ }
3464
+ /**
3465
+ * Whether the resolved configuration keeps this directory out of the program.
3466
+ *
3467
+ * Compared by physical containment rather than by name, so `outDir: "./dist"`
3468
+ * excludes that one directory instead of every directory called `dist` at every
3469
+ * depth, which is the distinction the name list could not draw.
3470
+ */
3471
+ function isExcludedProjectDirectory(directory, policy) {
3472
+ return insideExcludedProjectDirectory(directory, policy, false);
3473
+ }
3474
+ /**
3475
+ * Whether this entry could enter the program, and so whether its appearance or
3476
+ * removal is a membership change.
3477
+ *
3478
+ * A directory always could, since it can hold sources. A file could only if it
3479
+ * carries an extension the resolved configuration admits, which is what makes a
3480
+ * bundle emitted beside the sources invisible to a project that compiles no
3481
+ * JavaScript.
3482
+ */
3483
+ function isPossibleProgramEntry(entry, policy) {
3484
+ return entry.isFile() ? isPossibleProgramFileName(entry.name, policy) : true;
3485
+ }
3486
+ /** The same question for a bare file name, for callers holding no `Dirent`. */
3487
+ function isPossibleProgramFileName(name, policy) {
3488
+ const lowered = name.toLowerCase();
3489
+ return policy.inputExtensions.some((extension) => lowered.endsWith(extension));
2679
3490
  }
2680
3491
  /**
2681
3492
  * Compare two project-walk snapshots.
@@ -2732,6 +3543,103 @@ function walkSnapshotComplete(snapshot, declared) {
2732
3543
  }
2733
3544
  return true;
2734
3545
  }
3546
+ /** Preserve exact project-walk and mutation witnesses for one failed attempt. */
3547
+ function recordProjectSnapshotFailures(failures, props) {
3548
+ const recordWalk = (snapshot) => {
3549
+ for (const failure of snapshot.walkFailures) {
3550
+ if (failure.kind.startsWith("file-") && props.declared !== undefined) {
3551
+ try {
3552
+ const key = toProjectKey(props.projectRoot, failure.path, props.identities);
3553
+ if (!props.declared.has(key))
3554
+ continue;
3555
+ }
3556
+ catch {
3557
+ // An unidentifiable failed input taints the complete project walk.
3558
+ }
3559
+ }
3560
+ recordGenerationProofFailure(failures, {
3561
+ domain: "project",
3562
+ kind: failure.kind,
3563
+ path: failure.path,
3564
+ });
3565
+ }
3566
+ };
3567
+ recordWalk(props.before);
3568
+ recordWalk(props.snapshot);
3569
+ const keys = props.declared ??
3570
+ new Set([
3571
+ ...Object.keys(props.before.hashes),
3572
+ ...Object.keys(props.snapshot.hashes),
3573
+ ]);
3574
+ for (const key of keys) {
3575
+ if (props.before.hashes[key] !== props.snapshot.hashes[key]) {
3576
+ recordGenerationProofFailure(failures, {
3577
+ domain: "project",
3578
+ kind: "input-content-changed",
3579
+ path: path.resolve(props.projectRoot, key),
3580
+ });
3581
+ }
3582
+ if (props.before.fileSignatures[key] !== props.snapshot.fileSignatures[key]) {
3583
+ recordGenerationProofFailure(failures, {
3584
+ domain: "project",
3585
+ kind: "input-metadata-changed",
3586
+ path: path.resolve(props.projectRoot, key),
3587
+ });
3588
+ }
3589
+ }
3590
+ const leftDirectories = new Map(props.before.projectDirectories.map((entry) => [
3591
+ entry.path,
3592
+ entry.signature,
3593
+ ]));
3594
+ const rightDirectories = new Map(props.snapshot.projectDirectories.map((entry) => [
3595
+ entry.path,
3596
+ entry.signature,
3597
+ ]));
3598
+ for (const directory of new Set([
3599
+ ...leftDirectories.keys(),
3600
+ ...rightDirectories.keys(),
3601
+ ])) {
3602
+ if (leftDirectories.get(directory) !== rightDirectories.get(directory)) {
3603
+ recordGenerationProofFailure(failures, {
3604
+ domain: "project",
3605
+ kind: "directory-membership-changed",
3606
+ path: directory,
3607
+ });
3608
+ }
3609
+ }
3610
+ const recordTracker = (tracker, kind) => {
3611
+ if (tracker?.membershipChanged !== true)
3612
+ return;
3613
+ if (tracker.changes.size === 0) {
3614
+ recordGenerationProofFailure(failures, {
3615
+ domain: "project",
3616
+ kind,
3617
+ path: props.projectRoot,
3618
+ });
3619
+ return;
3620
+ }
3621
+ for (const changed of tracker.changes) {
3622
+ recordGenerationProofFailure(failures, {
3623
+ domain: "project",
3624
+ kind,
3625
+ path: changed,
3626
+ });
3627
+ }
3628
+ if (tracker.changesOmitted) {
3629
+ failures.omitted = Math.min(Number.MAX_SAFE_INTEGER, failures.omitted + 1);
3630
+ }
3631
+ };
3632
+ recordTracker(props.tracker, "project-membership-event");
3633
+ recordTracker(props.hostInputTracker, "host-input-event");
3634
+ recordTracker(props.candidateTracker, "candidate-event");
3635
+ if (failures.entries.length === 0) {
3636
+ recordGenerationProofFailure(failures, {
3637
+ domain: "project",
3638
+ kind: "snapshot-incomplete",
3639
+ path: props.projectRoot,
3640
+ });
3641
+ }
3642
+ }
2735
3643
  /** {@link selectDeclaredProjectInputKeys} memoized per envelope generation. */
2736
3644
  function declaredProjectInputKeys(state, cached) {
2737
3645
  if (state.declaredInputKeysBuilt !== true) {
@@ -2739,6 +3647,7 @@ function declaredProjectInputKeys(state, cached) {
2739
3647
  identities: state.identityContext,
2740
3648
  projectRoot: cached.projectRoot,
2741
3649
  result: cached.result,
3650
+ scratchDirectory: cached.scratchDirectory,
2742
3651
  });
2743
3652
  state.declaredInputKeysBuilt = true;
2744
3653
  }
@@ -2759,7 +3668,10 @@ function selectDeclaredProjectInputKeys(props) {
2759
3668
  const add = (entry) => {
2760
3669
  if (typeof entry !== "string" || entry.length === 0)
2761
3670
  return;
2762
- keys.add(toProjectKey(props.projectRoot, path.resolve(props.projectRoot, entry), props.identities));
3671
+ const absolute = path.resolve(props.projectRoot, entry);
3672
+ if (isTransformScratchInput(absolute, props.scratchDirectory))
3673
+ return;
3674
+ keys.add(toProjectKey(props.projectRoot, absolute, props.identities));
2763
3675
  };
2764
3676
  for (const [source, targets] of Object.entries(graph.edges ?? {})) {
2765
3677
  add(source);
@@ -2791,46 +3703,231 @@ function selectDeclaredProjectInputKeys(props) {
2791
3703
  }
2792
3704
  return keys;
2793
3705
  }
3706
+ /** Create an empty bounded witness collection for one transform attempt. */
3707
+ function createGenerationProofFailures() {
3708
+ return { entries: [], omitted: 0, seen: new Set() };
3709
+ }
3710
+ /** Retain one unique proof witness without allowing diagnostics to grow freely. */
3711
+ function recordGenerationProofFailure(failures, failure) {
3712
+ const key = JSON.stringify([
3713
+ failure.domain,
3714
+ failure.kind,
3715
+ failure.path,
3716
+ failure.detail,
3717
+ ]);
3718
+ if (failures.seen.has(key))
3719
+ return;
3720
+ if (failures.entries.length < MAX_GENERATION_PROOF_FAILURES) {
3721
+ // `seen` follows the same bound as `entries`: retaining every discarded
3722
+ // identity would make a bounded diagnostic an unbounded memory sink.
3723
+ failures.seen.add(key);
3724
+ failures.entries.push(failure);
3725
+ }
3726
+ else {
3727
+ failures.omitted = Math.min(Number.MAX_SAFE_INTEGER, failures.omitted + 1);
3728
+ }
3729
+ }
3730
+ /** Fold one bounded witness collection into another. */
3731
+ function mergeGenerationProofFailures(target, source) {
3732
+ for (const failure of source.entries) {
3733
+ recordGenerationProofFailure(target, failure);
3734
+ }
3735
+ target.omitted = Math.min(Number.MAX_SAFE_INTEGER, target.omitted + source.omitted);
3736
+ }
3737
+ /** Hash the declared-input-relevant failure shape without retaining it. */
3738
+ function projectWalkFailureFingerprint(snapshot, declared, projectRoot, identities) {
3739
+ const relevantUnstableFiles = declared === undefined
3740
+ ? [...snapshot.unstableFiles]
3741
+ : [...snapshot.unstableFiles].filter((key) => declared.has(key));
3742
+ const relevantFailures = snapshot.walkFailures.filter((failure) => {
3743
+ if (!failure.kind.startsWith("file-"))
3744
+ return true;
3745
+ if (declared === undefined)
3746
+ return true;
3747
+ try {
3748
+ return declared.has(toProjectKey(projectRoot, failure.path, identities));
3749
+ }
3750
+ catch {
3751
+ return true;
3752
+ }
3753
+ });
3754
+ return hashText(JSON.stringify({
3755
+ complete: walkSnapshotComplete(snapshot, declared),
3756
+ directoryComplete: snapshot.directoryComplete,
3757
+ failures: relevantFailures
3758
+ .map((failure) => `${failure.kind}\0${path.resolve(failure.path)}`)
3759
+ .sort(),
3760
+ unstableFiles: relevantUnstableFiles.sort(),
3761
+ }));
3762
+ }
3763
+ /** Compact state of one exact out-of-walk input in a failed generation. */
3764
+ function failedGenerationInputState(input, filesystem) {
3765
+ let directory = "not-directory";
3766
+ try {
3767
+ if (filesystem.stat(input).isDirectory()) {
3768
+ directory = hashText(filesystem
3769
+ .readdir(input)
3770
+ .map((entry) => [
3771
+ entry.name,
3772
+ entry.isDirectory(),
3773
+ entry.isFile(),
3774
+ entry.isSymbolicLink(),
3775
+ ].join(":"))
3776
+ .sort()
3777
+ .join("\0"));
3778
+ }
3779
+ }
3780
+ catch {
3781
+ directory = "unavailable";
3782
+ }
3783
+ return hashText(JSON.stringify([
3784
+ inputMetadataSignature(input, filesystem) ?? "missing",
3785
+ hostInputStateHash(input, filesystem) ?? MISSING_INPUT_STATE,
3786
+ hostInputRealpath(input, filesystem),
3787
+ directory,
3788
+ ]));
3789
+ }
3790
+ /** Snapshot every input outside the project walk that could change a retry. */
3791
+ function captureFailedGenerationInputStates(cached, failures) {
3792
+ const filesystem = resultFilesystem(cached.result);
3793
+ const inputs = new Set((cached.externalInputPaths ?? []).map((input) => path.resolve(input)));
3794
+ for (const input of selectPersistentHostInputs({
3795
+ filesystem,
3796
+ projectRoot: cached.projectRoot,
3797
+ result: cached.result,
3798
+ scratchDirectory: cached.scratchDirectory,
3799
+ temporaryTsconfig: cached.temporaryTsconfig,
3800
+ })) {
3801
+ inputs.add(path.resolve(input));
3802
+ }
3803
+ for (const failure of failures.entries) {
3804
+ if (failure.path !== undefined)
3805
+ inputs.add(path.resolve(failure.path));
3806
+ }
3807
+ return new Map([...inputs]
3808
+ .sort()
3809
+ .map((input) => [input, failedGenerationInputState(input, filesystem)]));
3810
+ }
3811
+ /** Capture source baselines for project and out-of-walk transform outputs. */
3812
+ function captureTransformSourceHashes(cached, currentFile, currentSourceHash) {
3813
+ const filesystem = resultFilesystem(cached.result);
3814
+ const identities = envelopeDerivation(cached).identityContext;
3815
+ const hashes = {};
3816
+ if (cached.result.type === "success") {
3817
+ for (const output of Object.keys(cached.result.typescript)) {
3818
+ const file = path.resolve(cached.projectRoot, output);
3819
+ const hash = hostInputStateHash(file, filesystem);
3820
+ if (hash !== null)
3821
+ hashes[pathIdentityKey(file, identities)] = hash;
3822
+ }
3823
+ }
3824
+ hashes[pathIdentityKey(currentFile, identities)] = currentSourceHash;
3825
+ return hashes;
3826
+ }
2794
3827
  /**
2795
- * Project roots already told they cannot reuse a compile, so a build reports
2796
- * the condition once instead of once per module.
2797
- */
2798
- const REPORTED_UNREUSABLE_GENERATIONS = new Set();
2799
- /**
2800
- * Report, once per project root, that a generation cannot be reused.
3828
+ * Whether a terminal proof failure's observed environment actually changed.
2801
3829
  *
2802
- * Every module of the build then recompiles the whole project, so the condition
2803
- * is the difference between one compile and one compile per module. It stayed
2804
- * invisible for the whole life of samchon/ttsc#970: consumers saw only a build
2805
- * that never finished, and each investigation had to rediscover the cause from
2806
- * outside. A named reason turns the next occurrence into a bug report instead
2807
- * of an archaeology session.
3830
+ * This is deliberately a confirmation test: inability to re-probe retains the
3831
+ * old verdict instead of turning every module request into another compile.
3832
+ * Cache lifecycle reset remains the unconditional recovery boundary.
2808
3833
  */
2809
- function reportUnreusableGeneration(cached, evidence) {
2810
- const missing = [
2811
- ...(evidence.walkStable ? [] : ["a stable project snapshot"]),
2812
- ...(evidence.graphProofs ? [] : ["compiler proofs for its graph inputs"]),
2813
- ...(evidence.externalInputs
2814
- ? []
2815
- : ["a complete out-of-walk input snapshot"]),
2816
- ...(evidence.universalInputs ? [] : ["a universal host-input manifest"]),
2817
- ];
2818
- const key = `${cached.projectRoot}\0${missing.join(",")}`;
2819
- if (REPORTED_UNREUSABLE_GENERATIONS.has(key)) {
2820
- return;
3834
+ function failedGenerationEnvironmentChanged(validation, props) {
3835
+ try {
3836
+ const identities = envelopeDerivation(validation.cached).identityContext;
3837
+ const currentSourceHash = hashText(props.currentSource);
3838
+ const expectedSourceHash = validation.cached.sourceHashes?.[pathIdentityKey(props.currentFile, identities)];
3839
+ if (expectedSourceHash !== undefined &&
3840
+ expectedSourceHash !== currentSourceHash) {
3841
+ return true;
3842
+ }
3843
+ const current = collectProjectInputSnapshot(validation.cached.projectRoot, identities, props.filesystem, undefined, { policy: validation.cached.membershipPolicy });
3844
+ if (validation.projectWalkComplete !==
3845
+ walkSnapshotComplete(current, validation.declaredInputs) ||
3846
+ validation.projectWalkFailures !==
3847
+ projectWalkFailureFingerprint(current, validation.declaredInputs, validation.cached.projectRoot, identities) ||
3848
+ !sameHashes(validation.projectInputHashes, current.hashes, validation.declaredInputs) ||
3849
+ !sameProjectDirectories(validation.cached.projectDirectories ?? [], current.projectDirectories)) {
3850
+ return true;
3851
+ }
3852
+ for (const [input, recorded] of validation.inputStates) {
3853
+ if (failedGenerationInputState(input, props.filesystem) !== recorded) {
3854
+ return true;
3855
+ }
3856
+ }
3857
+ return false;
2821
3858
  }
2822
- REPORTED_UNREUSABLE_GENERATIONS.add(key);
2823
- process.stderr.write(`ttsc: the transform cache cannot reuse this project's compile, so every ` +
2824
- `module recompiles the whole project.\n` +
2825
- ` project: ${cached.projectRoot}\n` +
2826
- ` missing: ${missing.join("; ")}\n` +
2827
- ` Please report this at https://github.com/samchon/ttsc/issues with ` +
2828
- `this message.\n`);
3859
+ catch {
3860
+ return false;
3861
+ }
3862
+ }
3863
+ /** Render one input without leaking source content or control characters. */
3864
+ function formatGenerationFailurePath(projectRoot, input) {
3865
+ const absolute = path.resolve(input);
3866
+ const relative = path.relative(projectRoot, absolute);
3867
+ const display = relative === ""
3868
+ ? "."
3869
+ : relative !== ".." &&
3870
+ !relative.startsWith(`..${path.sep}`) &&
3871
+ !path.isAbsolute(relative)
3872
+ ? relative
3873
+ : absolute;
3874
+ return JSON.stringify(display.split(path.sep).join("/"));
3875
+ }
3876
+ /** Build the terminal error shared by every waiter of an unstable generation. */
3877
+ function createUnstableGenerationError(projectRoot, attempts, validation) {
3878
+ const lines = [
3879
+ `ttsc: could not capture a reusable transform generation after ${attempts.length} attempts.`,
3880
+ ` project: ${projectRoot}`,
3881
+ ];
3882
+ attempts.forEach((failures, index) => {
3883
+ lines.push(` attempt ${index + 1}:`);
3884
+ if (failures.entries.length === 0) {
3885
+ lines.push(" - project/generation-proof-incomplete");
3886
+ }
3887
+ for (const failure of failures.entries) {
3888
+ const input = failure.path === undefined
3889
+ ? ""
3890
+ : `: ${formatGenerationFailurePath(projectRoot, failure.path)}`;
3891
+ const detail = failure.detail === undefined
3892
+ ? ""
3893
+ : ` (producer: ${JSON.stringify(failure.detail)})`;
3894
+ lines.push(` - ${failure.domain}/${failure.kind}${input}${detail}`);
3895
+ }
3896
+ if (failures.omitted !== 0) {
3897
+ lines.push(` - ... ${failures.omitted} additional witness(es) omitted`);
3898
+ }
3899
+ });
3900
+ lines.push(" Stop writes to the listed inputs before compilation, or fix the producer that omitted or contradicted the listed proof.");
3901
+ return new TtscUnstableGenerationError(lines.join("\n"), validation);
2829
3902
  }
2830
3903
  function hashText(input) {
2831
3904
  return crypto.createHash("sha256").update(input).digest("hex");
2832
3905
  }
2833
3906
  async function transformProject(props) {
3907
+ const attempts = [];
3908
+ for (let attempt = 0; attempt < TRANSFORM_GENERATION_ATTEMPTS; attempt += 1) {
3909
+ const cached = await captureTransformGeneration(props);
3910
+ if (!props.trackProjectMembership ||
3911
+ cached.result.type !== "success" ||
3912
+ cached.projectSnapshotComplete === true) {
3913
+ return cached;
3914
+ }
3915
+ attempts.push(TRANSFORM_GENERATION_FAILURES.get(cached.result) ??
3916
+ createGenerationProofFailures());
3917
+ if (attempt + 1 === TRANSFORM_GENERATION_ATTEMPTS) {
3918
+ const validation = TRANSFORM_FAILED_GENERATION_VALIDATIONS.get(cached.result);
3919
+ if (validation === undefined) {
3920
+ disposeCachedTransform(cached);
3921
+ throw new Error("ttsc: failed transform generation has no retry validation baseline");
3922
+ }
3923
+ throw createUnstableGenerationError(path.dirname(props.tsconfig), attempts, validation);
3924
+ }
3925
+ disposeCachedTransform(cached);
3926
+ }
3927
+ throw new Error("ttsc: transform generation retry loop did not terminate");
3928
+ }
3929
+ /** Capture one whole-project transform attempt and all of its reuse proofs. */
3930
+ async function captureTransformGeneration(props) {
2834
3931
  const projectRoot = path.dirname(props.tsconfig);
2835
3932
  const scratchDirectory = createTransformScratchDirectory(projectRoot, props.filesystem);
2836
3933
  let tracker;
@@ -2843,9 +3940,14 @@ async function transformProject(props) {
2843
3940
  const configured = createTransformTsconfig(props, scratchDirectory);
2844
3941
  const temporaryTsconfig = configured.path === props.tsconfig ? undefined : configured.path;
2845
3942
  const identities = createHostPathIdentityContext(props.filesystem);
2846
- const before = collectProjectInputSnapshot(projectRoot, identities, props.filesystem);
3943
+ // Read from the project's own tsconfig rather than the generated one: a
3944
+ // relative `outDir` is anchored at the config that declares it, and the
3945
+ // generated config lives in a system temp directory. The caller's
3946
+ // compiler-options overlay still wins, since it wins for the compile too.
3947
+ const membershipPolicy = tsconfigPaths.mergeMembershipPolicyOverlay(tsconfigPaths.readProjectMembershipPolicy(props.tsconfig), props.compilerOptions, projectRoot);
3948
+ const before = collectProjectInputSnapshot(projectRoot, identities, props.filesystem, undefined, { policy: membershipPolicy });
2847
3949
  tracker = props.trackProjectMembership
2848
- ? await createProjectMutationTracker(before.projectDirectories, props.filesystem)
3950
+ ? await createProjectMutationTracker(before.projectDirectories, props.filesystem, membershipPolicy)
2849
3951
  : undefined;
2850
3952
  const result = withTransformScratchEnvironment(scratchDirectory, () => new ttsc.TtscCompiler({
2851
3953
  cwd: projectRoot,
@@ -2870,6 +3972,7 @@ async function transformProject(props) {
2870
3972
  filesystem: props.filesystem,
2871
3973
  projectRoot,
2872
3974
  result,
3975
+ scratchDirectory,
2873
3976
  temporaryTsconfig,
2874
3977
  });
2875
3978
  // The generation's absent resolution candidates, which get a watcher of
@@ -2885,6 +3988,7 @@ async function transformProject(props) {
2885
3988
  filesystem: props.filesystem,
2886
3989
  projectRoot,
2887
3990
  result,
3991
+ scratchDirectory,
2888
3992
  temporaryTsconfig,
2889
3993
  })
2890
3994
  : { candidates: [], watched: [] };
@@ -2908,11 +4012,13 @@ async function transformProject(props) {
2908
4012
  : undefined;
2909
4013
  const externalInputPaths = selectExternalInputPaths({
2910
4014
  filesystem: props.filesystem,
4015
+ membershipPolicy,
2911
4016
  projectRoot,
2912
4017
  result,
4018
+ scratchDirectory,
2913
4019
  temporaryTsconfig,
2914
4020
  });
2915
- const inputSnapshot = collectProjectInputSnapshot(projectRoot, identities, props.filesystem);
4021
+ const inputSnapshot = collectProjectInputSnapshot(projectRoot, identities, props.filesystem, undefined, { policy: membershipPolicy });
2916
4022
  // Whether the recorded snapshot describes one coherent state of the
2917
4023
  // project. A membership event during the compile taints it exactly like an
2918
4024
  // unstable walk pair; whether notifications can be *opened* is a separate
@@ -2922,6 +4028,7 @@ async function transformProject(props) {
2922
4028
  identities,
2923
4029
  projectRoot,
2924
4030
  result,
4031
+ scratchDirectory,
2925
4032
  });
2926
4033
  const walkStable = walkSnapshotComplete(before, declaredInputs) &&
2927
4034
  walkSnapshotComplete(inputSnapshot, declaredInputs) &&
@@ -2937,29 +4044,44 @@ async function transformProject(props) {
2937
4044
  // Overlay the in-memory source only after proving the two on-disk snapshots
2938
4045
  // stable; an unsaved editor buffer must not look like a compile-time race.
2939
4046
  const currentFileKey = toProjectKey(projectRoot, props.currentFile, identities);
2940
- inputSnapshot.hashes[currentFileKey] = hashText(props.currentSource);
2941
- // That overlay makes this one key the only recorded hash a disk signature
2942
- // cannot stand for: the bytes it names came from the bundler, not the file.
2943
- delete inputSnapshot.provenSignatures[currentFileKey];
4047
+ const currentSourceHash = hashText(props.currentSource);
4048
+ const projectInputHashes = { ...inputSnapshot.hashes };
4049
+ if (Object.prototype.hasOwnProperty.call(inputSnapshot.hashes, currentFileKey)) {
4050
+ inputSnapshot.hashes[currentFileKey] = currentSourceHash;
4051
+ // That overlay makes this one key the only recorded hash a disk signature
4052
+ // cannot stand for: the bytes it names came from the bundler, not the file.
4053
+ delete inputSnapshot.provenSignatures[currentFileKey];
4054
+ }
2944
4055
  const cached = {
4056
+ // The pass this compile was started for. Its snapshot describes the
4057
+ // project as of this compile, so it is settled for this pass and any
4058
+ // later pass must re-prove it.
4059
+ ...(props.deliveryEpoch === undefined
4060
+ ? {}
4061
+ : { deliveryEpoch: props.deliveryEpoch }),
2945
4062
  // Capture the out-of-walk input hashes while the generation is fresh so
2946
4063
  // cache validation can re-check them; computed before dispose so the
2947
- // exclusion of the temp-dir tsconfig is the only reason it never keys.
4064
+ // scratch-tree exclusion is the only reason its disposed artifacts never
4065
+ // key the persistent generation.
2948
4066
  externalInputHashes: {},
2949
4067
  externalInputRealpaths: {},
2950
4068
  externalInputPaths,
2951
4069
  inputHashes: inputSnapshot.hashes,
2952
4070
  inputSignatures: inputSnapshot.provenSignatures,
4071
+ membershipPolicy,
2953
4072
  projectDirectories: inputSnapshot.projectDirectories,
4073
+ tsconfig: props.tsconfig,
2954
4074
  projectSnapshotComplete: false,
2955
4075
  projectRoot,
2956
4076
  result,
4077
+ scratchDirectory,
2957
4078
  servedFiles: new Set(),
2958
4079
  // Remember the generated temp-dir tsconfig (disposed below) so watch
2959
4080
  // derivation can drop it from the envelope's config chain; a registered
2960
4081
  // but deleted file would invalidate every persistent-cache snapshot.
2961
4082
  ...(temporaryTsconfig === undefined ? {} : { temporaryTsconfig }),
2962
4083
  };
4084
+ cached.sourceHashes = captureTransformSourceHashes(cached, props.currentFile, currentSourceHash);
2963
4085
  const externalInputSnapshot = captureExternalInputSnapshot(cached, externalInputPaths);
2964
4086
  cached.externalInputHashes = externalInputSnapshot.hashes;
2965
4087
  cached.externalInputRealpaths = externalInputSnapshot.realpaths;
@@ -2968,21 +4090,39 @@ async function transformProject(props) {
2968
4090
  // cannot be reused can say which evidence it lacked. The extra work runs
2969
4091
  // only on the failing path, where the alternative is recompiling the whole
2970
4092
  // project for every remaining module.
2971
- const graphProofs = matchesCompilerGraphInputProofs(cached);
2972
- const universalInputs = captureUniversalHostInputValidation(cached, props.currentFile) !==
2973
- undefined;
4093
+ const failures = createGenerationProofFailures();
4094
+ if (!walkStable) {
4095
+ recordProjectSnapshotFailures(failures, {
4096
+ before,
4097
+ candidateTracker,
4098
+ declared: declaredInputs,
4099
+ hostInputTracker,
4100
+ identities,
4101
+ projectRoot,
4102
+ snapshot: inputSnapshot,
4103
+ tracker,
4104
+ });
4105
+ }
4106
+ const graphFailures = compilerGraphInputProofFailures(cached);
4107
+ mergeGenerationProofFailures(failures, graphFailures);
4108
+ mergeGenerationProofFailures(failures, externalInputSnapshot.failures);
4109
+ const universalInputCapture = captureUniversalHostInputValidation(cached, props.currentFile);
4110
+ mergeGenerationProofFailures(failures, universalInputCapture.failures);
4111
+ const graphProofs = graphFailures.entries.length === 0 && graphFailures.omitted === 0;
4112
+ const universalInputs = universalInputCapture.validation !== undefined;
2974
4113
  const stableProjectSnapshot = walkStable &&
2975
4114
  graphProofs &&
2976
4115
  externalInputSnapshot.complete &&
2977
4116
  universalInputs;
2978
- // Only a caching host loses anything here: without a cache every delivery
2979
- // compiles by design, so an unprovable generation costs it nothing.
2980
- if (!stableProjectSnapshot && props.trackProjectMembership) {
2981
- reportUnreusableGeneration(cached, {
2982
- externalInputs: externalInputSnapshot.complete,
2983
- graphProofs,
2984
- universalInputs,
2985
- walkStable,
4117
+ if (!stableProjectSnapshot) {
4118
+ TRANSFORM_GENERATION_FAILURES.set(result, failures);
4119
+ TRANSFORM_FAILED_GENERATION_VALIDATIONS.set(result, {
4120
+ cached,
4121
+ declaredInputs,
4122
+ inputStates: captureFailedGenerationInputStates(cached, failures),
4123
+ projectInputHashes,
4124
+ projectWalkComplete: walkSnapshotComplete(inputSnapshot, declaredInputs),
4125
+ projectWalkFailures: projectWalkFailureFingerprint(inputSnapshot, declaredInputs, projectRoot, identities),
2986
4126
  });
2987
4127
  }
2988
4128
  cached.projectSnapshotComplete = stableProjectSnapshot;
@@ -3033,16 +4173,23 @@ async function transformProject(props) {
3033
4173
  }
3034
4174
  }
3035
4175
  }
3036
- /** Exclude the disposed overlay tsconfig from live host-input tracking. */
4176
+ /** Exclude disposed transform scratch from live host-input tracking. */
3037
4177
  function selectPersistentHostInputs(props) {
3038
4178
  if (props.result.type === "exception")
3039
4179
  return [];
3040
4180
  const inputs = selectListedFiles(props.projectRoot, props.result.hostInputs);
3041
- if (props.temporaryTsconfig === undefined)
4181
+ if (props.scratchDirectory === undefined &&
4182
+ props.temporaryTsconfig === undefined)
3042
4183
  return inputs;
3043
4184
  const identities = createHostPathIdentityContext(props.filesystem);
3044
- const temporary = pathIdentityKey(props.temporaryTsconfig, identities);
3045
- return inputs.filter((input) => pathIdentityKey(input, identities) !== temporary);
4185
+ const temporary = props.temporaryTsconfig === undefined
4186
+ ? undefined
4187
+ : pathIdentityKey(props.temporaryTsconfig, identities);
4188
+ return inputs.filter((input) => {
4189
+ if (isTransformScratchInput(input, props.scratchDirectory))
4190
+ return false;
4191
+ return pathIdentityKey(input, identities) !== temporary;
4192
+ });
3046
4193
  }
3047
4194
  function createTransformTsconfig(props, scratchDirectory) {
3048
4195
  const compilerOptions = normalizeCompilerOptionsForGeneratedTsconfig({
@@ -3133,6 +4280,11 @@ function pathIsWithin(child, parent) {
3133
4280
  !relative.startsWith(`..${path.sep}`) &&
3134
4281
  !path.isAbsolute(relative)));
3135
4282
  }
4283
+ /** Whether an input is owned by the disposable transform scratch tree. */
4284
+ function isTransformScratchInput(input, scratchDirectory) {
4285
+ return (scratchDirectory !== undefined &&
4286
+ pathIsWithin(path.resolve(input), path.resolve(scratchDirectory)));
4287
+ }
3136
4288
  /** Route all compiler/plugin scratch to one owned directory outside project. */
3137
4289
  function transformScratchEnvironment(directory) {
3138
4290
  return {
@@ -3278,10 +4430,37 @@ function readPaths(value) {
3278
4430
  function createAliasPaths(aliases) {
3279
4431
  const paths = {};
3280
4432
  for (const alias of normalizeAliases(aliases)) {
3281
- if (typeof alias.find !== "string" || alias.find.length === 0) {
4433
+ if (typeof alias.find !== "string") {
4434
+ // Vite's array form accepts a `RegExp` find, and `{ find: /^~/ }` is a
4435
+ // common way to spell a prefix alias. A tsconfig `paths` map has no
4436
+ // regular-expression form, so there is nothing to translate it into
4437
+ // (samchon/ttsc#1315). Reducing the simple prefix cases to a string is
4438
+ // possible in principle and deliberately not done: telling `/^~/` from
4439
+ // `/^~(?=\/)/` or `/^@app/` — which matches `@apple` too — means
4440
+ // implementing enough of a regular-expression engine that a wrong
4441
+ // reduction becomes likely, and a mistranslated alias resolves imports to
4442
+ // the wrong file silently, which is worse than not forwarding it.
4443
+ //
4444
+ // Not reported, unlike the wildcard below, and that asymmetry is the
4445
+ // whole point: Vite merges two `RegExp` aliases of its own into every
4446
+ // resolved config, `/^\/?@vite\/env/` and `/^\/?@vite\/client/`. Measured
4447
+ // on a bare project with no user aliases at all, `resolve.alias` has
4448
+ // exactly those two entries under both `serve` and `build`, so a report
4449
+ // on this form would fire twice for every Vite user in every build, name
4450
+ // aliases they never wrote, and say nothing about their configuration.
4451
+ // A diagnostic that cannot distinguish the user's input from the host's
4452
+ // is noise, and noise is what teaches people to stop reading the channel
4453
+ // the out-of-program report depends on. The documentation carries this
4454
+ // form instead, in both README and guide.
4455
+ continue;
4456
+ }
4457
+ if (alias.find.length === 0) {
3282
4458
  continue;
3283
4459
  }
3284
4460
  if (alias.find.includes("*")) {
4461
+ // A `paths` key reads `*` as its own wildcard, so forwarding a `find`
4462
+ // that already contains one cannot preserve the caller's meaning.
4463
+ reportUntranslatableAlias(JSON.stringify(alias.find), 'a "paths" key already reads "*" as its own wildcard');
3285
4464
  continue;
3286
4465
  }
3287
4466
  const key = alias.find.replace(/\/+$/, "");
@@ -3296,9 +4475,50 @@ function createAliasPaths(aliases) {
3296
4475
  }
3297
4476
  return paths;
3298
4477
  }
4478
+ /**
4479
+ * Alias descriptions already reported in this process.
4480
+ *
4481
+ * The message is about configuration rather than about a module:
4482
+ * `resolve.alias` is resolved once and then consulted on every delivery, so
4483
+ * reporting per delivery would repeat one statement about the config for every
4484
+ * file in the bundle. Keyed by the description, so a Vite dev server that
4485
+ * reloads its config reports again only when the alias itself changed.
4486
+ */
4487
+ const REPORTED_UNTRANSLATABLE_ALIASES = new Set();
4488
+ /**
4489
+ * Tell the user once that an alias they declared is not reaching the compile.
4490
+ *
4491
+ * A dropped alias is not silent in its consequence — the compile resolves
4492
+ * through the tsconfig's own `paths`, and a module that resolves for the
4493
+ * bundler but not for the compiler surfaces as the out-of-program report
4494
+ * (samchon/ttsc#1308) — but that report names the module, not the alias, so the
4495
+ * user cannot learn from it that a configuration they wrote was ignored.
4496
+ *
4497
+ * Only the wildcard form reaches here. Every entry it names was written by the
4498
+ * user, because nothing injects one; the `RegExp` form is left to the
4499
+ * documentation precisely because Vite does inject those, and
4500
+ * {@link createAliasPaths} carries that measurement.
4501
+ */
4502
+ function reportUntranslatableAlias(description, reason) {
4503
+ if (REPORTED_UNTRANSLATABLE_ALIASES.has(description)) {
4504
+ return;
4505
+ }
4506
+ REPORTED_UNTRANSLATABLE_ALIASES.add(description);
4507
+ process.stderr.write(`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`);
4508
+ }
4509
+ /**
4510
+ * Collect the host's declared aliases without deciding which of them can be
4511
+ * expressed as `paths`.
4512
+ *
4513
+ * That decision belongs to {@link createAliasPaths} alone. It used to be split:
4514
+ * this function's type guard required a string `find` and dropped Vite's
4515
+ * `RegExp` form before `createAliasPaths` ever saw it, which left
4516
+ * `createAliasPaths`'s own non-string branch unreachable and put the drop
4517
+ * somewhere nothing could report it (samchon/ttsc#1315).
4518
+ */
3299
4519
  function normalizeAliases(aliases) {
3300
4520
  if (Array.isArray(aliases)) {
3301
- return aliases.filter(isAlias);
4521
+ return aliases.filter(isDeclaredAlias);
3302
4522
  }
3303
4523
  if (typeof aliases === "object" && aliases !== null) {
3304
4524
  return Object.entries(aliases)
@@ -3343,12 +4563,11 @@ function isRelativeSpecifier(value) {
3343
4563
  value.startsWith(".\\") ||
3344
4564
  value.startsWith("..\\"));
3345
4565
  }
3346
- function isAlias(value) {
4566
+ function isDeclaredAlias(value) {
3347
4567
  return (typeof value === "object" &&
3348
4568
  value !== null &&
3349
4569
  "find" in value &&
3350
4570
  "replacement" in value &&
3351
- typeof value.find === "string" &&
3352
4571
  typeof value.replacement === "string");
3353
4572
  }
3354
4573
  /**
@@ -3381,19 +4600,57 @@ function selectTransformedSource(props) {
3381
4600
  if (source !== undefined) {
3382
4601
  return source;
3383
4602
  }
3384
- throw new Error(`ttsc transform did not return output for ${props.file}`);
4603
+ throw new TtscMissingProgramOutputError(props.file, props.tsconfig);
4604
+ }
4605
+ /**
4606
+ * Tell the user once that a module was left untransformed, and why.
4607
+ *
4608
+ * The condition is ordinary and the build continues, but it must never be
4609
+ * silent: a file the program does not contain keeps whatever plugin syntax it
4610
+ * carries, so a typia `assert<T>()` in it becomes a runtime failure rather than
4611
+ * a build failure. One line per file per generation per pass, on the channel
4612
+ * the generation's other non-fatal diagnostics already use, so a bundle that
4613
+ * reaches many such files does not repeat itself per delivery.
4614
+ */
4615
+ function reportMissingProgramOutput(cached, error, epoch) {
4616
+ const reported = (cached.missingOutputReported ??= new Set());
4617
+ if (cached.missingOutputEpoch !== epoch) {
4618
+ cached.missingOutputEpoch = epoch;
4619
+ reported.clear();
4620
+ }
4621
+ if (reported.has(error.file)) {
4622
+ return;
4623
+ }
4624
+ reported.add(error.file);
4625
+ process.stderr.write(`${error.message}
4626
+ `);
3385
4627
  }
3386
4628
  /**
3387
- * Forward non-fatal plugin diagnostics to stderr.
4629
+ * Forward non-fatal plugin diagnostics to stderr, once per generation per pass.
3388
4630
  *
3389
4631
  * A `success` result may still carry warnings or informational messages from
3390
- * plugins. These are surfaced via stderr rather than throwing so the build
3391
- * continues. Failures and exceptions are handled by the caller.
4632
+ * plugins `@ttsc/lint` reports every rule below error severity this way.
4633
+ * These are surfaced via stderr rather than throwing so the build continues.
4634
+ * Failures and exceptions are handled by the caller.
4635
+ *
4636
+ * They describe one compile of one program, so writing them per delivery
4637
+ * printed the same warning once per module and scaled the noise with exactly
4638
+ * the reuse the cache exists to provide (samchon/ttsc#1304). A pass that reuses
4639
+ * a retained generation still surfaces them once, because a build's warnings
4640
+ * are part of what that build reports; a host with no pass boundary surfaces
4641
+ * them once per generation, which is the same rule with one pass.
3392
4642
  */
3393
- function reportSuccessDiagnostics(result) {
4643
+ function reportSuccessDiagnostics(cached, epoch) {
4644
+ const result = cached.result;
3394
4645
  if (result.type !== "success" || result.diagnostics === undefined) {
3395
4646
  return;
3396
4647
  }
4648
+ if (cached.diagnosticsReported === true &&
4649
+ cached.diagnosticsEpoch === epoch) {
4650
+ return;
4651
+ }
4652
+ cached.diagnosticsReported = true;
4653
+ cached.diagnosticsEpoch = epoch;
3397
4654
  const text = formatDiagnostics(result.diagnostics);
3398
4655
  if (text.length !== 0) {
3399
4656
  process.stderr.write(`${text}\n`);
@@ -3417,7 +4674,7 @@ function formatDiagnostics(diagnostics) {
3417
4674
  diag.line === undefined
3418
4675
  ? undefined
3419
4676
  : `${diag.line}:${diag.character ?? 1}`,
3420
- diag.messageText,
4677
+ stripTerminalEscapes(diag.messageText),
3421
4678
  ]
3422
4679
  .filter((part) => part !== undefined && part !== "")
3423
4680
  .join(": "))
@@ -3425,15 +4682,38 @@ function formatDiagnostics(diagnostics) {
3425
4682
  }
3426
4683
  function formatUnknownError(error) {
3427
4684
  if (error instanceof Error) {
3428
- return error.message;
4685
+ return stripTerminalEscapes(error.message);
3429
4686
  }
3430
4687
  if (typeof error === "object" &&
3431
4688
  error !== null &&
3432
4689
  "message" in error &&
3433
4690
  typeof error.message === "string") {
3434
- return error.message;
4691
+ return stripTerminalEscapes(error.message);
3435
4692
  }
3436
- return String(error);
4693
+ return stripTerminalEscapes(String(error));
4694
+ }
4695
+ /**
4696
+ * Remove terminal colour and cursor sequences from text the adapter surfaces.
4697
+ *
4698
+ * An ordinary type error reaches the adapter as an `"exception"` envelope whose
4699
+ * `error` is the host's own rendered output, colour and all, and the envelope
4700
+ * carries no structured diagnostics to format instead. What the adapter hands
4701
+ * back is not going to a terminal: it becomes the `Error` a bundler reports, so
4702
+ * it lands in a Vite overlay, a webpack error report or a CI annotation, where
4703
+ * the escapes render as literal noise around the file and line the reader needs
4704
+ * (samchon/ttsc#1312).
4705
+ *
4706
+ * The colour originates in the host's rendering rather than in anything this
4707
+ * adapter configures, so this is the adapter-side repair, applied to every
4708
+ * message it surfaces rather than to one call site.
4709
+ */
4710
+ function stripTerminalEscapes(text) {
4711
+ // Built from a char code so no control byte lives in this source file, and
4712
+ // written with `[[]` (a class holding one literal bracket) so the pattern
4713
+ // needs no backslash escapes to survive the string it is assembled from.
4714
+ const escape = String.fromCharCode(27);
4715
+ const controlSequence = new RegExp(escape + "[[][0-9;?]*[ -/]*[@-~]", "g");
4716
+ return text.replace(controlSequence, "");
3437
4717
  }
3438
4718
  /**
3439
4719
  * Locate the tsconfig that should govern the transform for `file`.