@ttsc/unplugin 0.28.1 → 0.28.3

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 (49) hide show
  1. package/README.md +3 -1
  2. package/lib/api.d.cts +8 -0
  3. package/lib/api.d.mts +8 -0
  4. package/lib/bun-register.d.cts +25 -0
  5. package/lib/bun-register.d.mts +25 -0
  6. package/lib/bun.d.cts +95 -0
  7. package/lib/bun.d.mts +95 -0
  8. package/lib/core/index.d.cts +23 -0
  9. package/lib/core/index.d.mts +23 -0
  10. package/lib/core/index.js +18 -1
  11. package/lib/core/index.js.map +1 -1
  12. package/lib/core/index.mjs +19 -2
  13. package/lib/core/index.mjs.map +1 -1
  14. package/lib/core/options.d.cts +54 -0
  15. package/lib/core/options.d.mts +54 -0
  16. package/lib/core/transform.d.cts +433 -0
  17. package/lib/core/transform.d.mts +433 -0
  18. package/lib/core/transform.d.ts +23 -4
  19. package/lib/core/transform.js +715 -117
  20. package/lib/core/transform.js.map +1 -1
  21. package/lib/core/transform.mjs +715 -117
  22. package/lib/core/transform.mjs.map +1 -1
  23. package/lib/core/tsconfigPaths.d.cts +29 -0
  24. package/lib/core/tsconfigPaths.d.mts +29 -0
  25. package/lib/core/viteServe.d.cts +81 -0
  26. package/lib/core/viteServe.d.mts +81 -0
  27. package/lib/esbuild.d.cts +3 -0
  28. package/lib/esbuild.d.mts +3 -0
  29. package/lib/farm.d.cts +3 -0
  30. package/lib/farm.d.mts +3 -0
  31. package/lib/index.d.cts +12 -0
  32. package/lib/index.d.mts +12 -0
  33. package/lib/next.d.cts +37 -0
  34. package/lib/next.d.mts +37 -0
  35. package/lib/rolldown.d.cts +3 -0
  36. package/lib/rolldown.d.mts +3 -0
  37. package/lib/rollup.d.cts +3 -0
  38. package/lib/rollup.d.mts +3 -0
  39. package/lib/rspack.d.cts +3 -0
  40. package/lib/rspack.d.mts +3 -0
  41. package/lib/turbopack.d.cts +58 -0
  42. package/lib/turbopack.d.mts +58 -0
  43. package/lib/vite.d.cts +3 -0
  44. package/lib/vite.d.mts +3 -0
  45. package/lib/webpack.d.cts +3 -0
  46. package/lib/webpack.d.mts +3 -0
  47. package/package.json +122 -17
  48. package/src/core/index.ts +18 -1
  49. package/src/core/transform.ts +1014 -139
@@ -7,6 +7,27 @@ import { TtscCompiler } from 'ttsc';
7
7
  import { createFilesystemPathIdentityContext } from 'ttsc/path-identity';
8
8
  import { absolutizePathsTarget, readEffectiveTsconfigPaths } from './tsconfigPaths.mjs';
9
9
 
10
+ /** A bounded proof failure that stays authoritative until its inputs change. */
11
+ class TtscUnstableGenerationError extends Error {
12
+ validation;
13
+ constructor(message, validation) {
14
+ super(message);
15
+ this.name = "TtscUnstableGenerationError";
16
+ this.validation = validation;
17
+ }
18
+ }
19
+ /** Proof witnesses retained beside a compiler result without extending its API. */
20
+ const TRANSFORM_GENERATION_FAILURES = new WeakMap();
21
+ /** Retry baselines retained only for attempts that could not be published. */
22
+ const TRANSFORM_FAILED_GENERATION_VALIDATIONS = new WeakMap();
23
+ /** Rejected cache promises whose unchanged terminal verdict may be replayed. */
24
+ const TERMINAL_TRANSFORM_GENERATIONS = new WeakMap();
25
+ /** Maximum witnesses printed and retained for each failed transform attempt. */
26
+ const MAX_GENERATION_PROOF_FAILURES = 8;
27
+ /** Maximum exact mutation paths kept after a tracker already proved a change. */
28
+ const MAX_GENERATION_MUTATION_PATHS = 8;
29
+ /** One retry absorbs a transient watch write without admitting an infinite loop. */
30
+ const TRANSFORM_GENERATION_ATTEMPTS = 2;
10
31
  const DEFAULT_FILESYSTEM_OPERATIONS = Object.freeze({
11
32
  exists: fs.existsSync,
12
33
  lstat: (location) => fs.lstatSync(location, { bigint: true }),
@@ -140,8 +161,27 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
140
161
  for (;;) {
141
162
  let transformed = cache?.get(key);
142
163
  if (transformed !== undefined) {
143
- // A rejected in-flight generation must not stay cached: evict it (only if
144
- // it is still the current entry) so a later call re-runs the transform.
164
+ const terminal = TERMINAL_TRANSFORM_GENERATIONS.get(transformed);
165
+ if (terminal !== undefined) {
166
+ // A proof failure is a verdict about one observed environment, not an
167
+ // invitation for every later module to repeat the whole compile. Keep
168
+ // replaying it until a source/input probe or an explicit cache reset
169
+ // establishes that a new generation could differ.
170
+ if (!failedGenerationEnvironmentChanged(terminal.validation, {
171
+ currentFile: file,
172
+ currentSource: source,
173
+ filesystem,
174
+ })) {
175
+ throw terminal;
176
+ }
177
+ evictGeneration(cache, key, transformed);
178
+ if (cache?.get(key) !== undefined) {
179
+ continue;
180
+ }
181
+ transformed = undefined;
182
+ }
183
+ }
184
+ if (transformed !== undefined) {
145
185
  const cached = await awaitOrEvict(cache, key, transformed);
146
186
  TRANSFORM_RESULT_FILESYSTEM.set(cached.result, filesystem);
147
187
  // While this caller awaited the old Promise, another caller may have
@@ -221,20 +261,28 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
221
261
  }
222
262
  }
223
263
  /**
224
- * Await a cached generation, evicting it on rejection.
264
+ * Await a cached generation, retaining only terminal proof failures.
225
265
  *
226
266
  * The cache stores the in-flight transform Promise before it settles so
227
- * concurrent callers share one compilation. A rejected generation must not
228
- * remain the authoritative cached result, or a transient toolchain/host failure
229
- * becomes permanent for a long-lived worker. Eviction is identity-guarded so a
230
- * newer generation another caller installed under the same key survives.
267
+ * concurrent callers share one compilation. Ordinary compiler and host
268
+ * rejections are evicted so a transient failure cannot become permanent. A
269
+ * bounded stabilization failure is different: it already spent its retry and
270
+ * repeating it for every later module recreates the issue this gate prevents.
271
+ * It stays authoritative until its retained input baseline changes or the cache
272
+ * owner starts a new lifecycle.
231
273
  */
232
274
  async function awaitOrEvict(cache, key, generation) {
233
275
  try {
234
276
  return await generation;
235
277
  }
236
278
  catch (error) {
237
- evictGeneration(cache, key, generation);
279
+ if (error instanceof TtscUnstableGenerationError &&
280
+ cache?.get(key) === generation) {
281
+ TERMINAL_TRANSFORM_GENERATIONS.set(generation, error);
282
+ }
283
+ else {
284
+ evictGeneration(cache, key, generation);
285
+ }
238
286
  throw error;
239
287
  }
240
288
  }
@@ -316,6 +364,7 @@ function envelopeGraphIndexes(state, props) {
316
364
  members: new Set(),
317
365
  speculative: new Set(),
318
366
  inputProofs: new Map(),
367
+ inputProofFailures: new Map(),
319
368
  inputProofConflicts: new Set(),
320
369
  };
321
370
  const graph = props.result.type === "exception" ? undefined : props.result.graph;
@@ -333,7 +382,11 @@ function envelopeGraphIndexes(state, props) {
333
382
  .filter((target) => typeof target === "string" && target.length !== 0)
334
383
  .map((target) => {
335
384
  const absoluteTarget = path.resolve(props.projectRoot, target);
336
- built.members.add(derivationIdentity(state, absoluteTarget));
385
+ const targetIdentity = derivationIdentity(state, absoluteTarget);
386
+ built.members.add(targetIdentity);
387
+ if (!built.spellings.has(targetIdentity)) {
388
+ built.spellings.set(targetIdentity, absoluteTarget);
389
+ }
337
390
  return absoluteTarget;
338
391
  }));
339
392
  built.edges.set(identity, entries);
@@ -341,7 +394,10 @@ function envelopeGraphIndexes(state, props) {
341
394
  built.globals.push(...selectListedFiles(props.projectRoot, graph.globals));
342
395
  built.configs.push(...selectListedFiles(props.projectRoot, graph.configs));
343
396
  for (const input of [...built.globals, ...built.configs]) {
344
- built.members.add(derivationIdentity(state, input));
397
+ const identity = derivationIdentity(state, input);
398
+ built.members.add(identity);
399
+ if (!built.spellings.has(identity))
400
+ built.spellings.set(identity, input);
345
401
  }
346
402
  const candidateEntries = Object.entries(graph.candidates ?? {}).filter((entry) => Array.isArray(entry[1]));
347
403
  // Every candidate source is an importing file the compiler read, so fold
@@ -349,7 +405,12 @@ function envelopeGraphIndexes(state, props) {
349
405
  // candidate could be classified speculative before a later entry proves
350
406
  // the same path is a realized source.
351
407
  for (const [source] of candidateEntries) {
352
- built.members.add(derivationIdentity(state, path.resolve(props.projectRoot, source)));
408
+ const absoluteSource = path.resolve(props.projectRoot, source);
409
+ const identity = derivationIdentity(state, absoluteSource);
410
+ built.members.add(identity);
411
+ if (!built.spellings.has(identity)) {
412
+ built.spellings.set(identity, absoluteSource);
413
+ }
353
414
  }
354
415
  const realized = new Set(built.members);
355
416
  for (const [source, candidates] of candidateEntries) {
@@ -367,6 +428,17 @@ function envelopeGraphIndexes(state, props) {
367
428
  if (!realized.has(identity))
368
429
  built.speculative.add(identity);
369
430
  built.members.add(identity);
431
+ if (!built.spellings.has(identity)) {
432
+ built.spellings.set(identity, path.resolve(props.projectRoot, candidate));
433
+ }
434
+ }
435
+ }
436
+ const transformSources = new Set();
437
+ if (props.result.type === "success") {
438
+ for (const output of Object.keys(props.result.typescript)) {
439
+ if (!isDeclarationFile(output)) {
440
+ transformSources.add(derivationIdentity(state, path.resolve(props.projectRoot, output)));
441
+ }
370
442
  }
371
443
  }
372
444
  for (const [input, hash] of Object.entries(graph.inputHashes ?? {})) {
@@ -386,8 +458,9 @@ function envelopeGraphIndexes(state, props) {
386
458
  }
387
459
  const absolute = path.resolve(props.projectRoot, input);
388
460
  const identity = derivationIdentity(state, absolute);
389
- if (!built.members.has(identity))
461
+ if (!built.members.has(identity) && !transformSources.has(identity)) {
390
462
  continue;
463
+ }
391
464
  const proof = {
392
465
  hash,
393
466
  path: absolute,
@@ -404,6 +477,23 @@ function envelopeGraphIndexes(state, props) {
404
477
  built.inputProofs.set(identity, proof);
405
478
  }
406
479
  }
480
+ for (const [input, reason] of Object.entries(graph.inputProofFailures ?? {})) {
481
+ if (typeof reason !== "string" || !/^[a-z0-9-]{1,64}$/.test(reason)) {
482
+ continue;
483
+ }
484
+ const absolute = path.resolve(props.projectRoot, input);
485
+ const identity = derivationIdentity(state, absolute);
486
+ if (!built.members.has(identity) && !transformSources.has(identity)) {
487
+ continue;
488
+ }
489
+ if (built.inputProofs.has(identity)) {
490
+ built.inputProofs.delete(identity);
491
+ built.inputProofConflicts.add(identity);
492
+ }
493
+ if (!built.inputProofFailures.has(identity)) {
494
+ built.inputProofFailures.set(identity, reason);
495
+ }
496
+ }
407
497
  }
408
498
  state.graph = built;
409
499
  return built;
@@ -450,8 +540,9 @@ function collectDeclaredIdentities(state, projectRoot, listed) {
450
540
  * Envelope keys mirror the `typescript` keys (project-relative); values may be
451
541
  * project-relative or absolute. Every path is absolutized against the project
452
542
  * root and deduplicated; the file itself is dropped (the bundler already
453
- * watches the module it transforms), and so is the disposed temp-dir tsconfig
454
- * (see {@link TtscCachedProjectTransform.temporaryTsconfig}).
543
+ * watches the module it transforms), and so is every path in the disposed
544
+ * transform scratch tree (see
545
+ * {@link TtscCachedProjectTransform.scratchDirectory}).
455
546
  */
456
547
  function notifyWatchInputs(hooks, cached, file) {
457
548
  const addWatchFile = hooks?.addWatchFile;
@@ -464,6 +555,7 @@ function notifyWatchInputs(hooks, cached, file) {
464
555
  file,
465
556
  projectRoot: cached.projectRoot,
466
557
  result: cached.result,
558
+ scratchDirectory: cached.scratchDirectory,
467
559
  temporaryTsconfig: cached.temporaryTsconfig,
468
560
  })) {
469
561
  // Hand the adapter the identity this generation already resolved and the
@@ -533,6 +625,7 @@ function deriveWatchInputs(state, props, fileIdentity) {
533
625
  const spelling = path.resolve(input);
534
626
  if (spelling === currentSpelling ||
535
627
  spelling === temporarySpelling ||
628
+ isTransformScratchInput(spelling, props.scratchDirectory) ||
536
629
  lexicalSeen.has(spelling)) {
537
630
  return;
538
631
  }
@@ -541,6 +634,8 @@ function deriveWatchInputs(state, props, fileIdentity) {
541
634
  output.push(input);
542
635
  };
543
636
  const appendPhysical = (input) => {
637
+ if (isTransformScratchInput(input, props.scratchDirectory))
638
+ return;
544
639
  const identity = derivationIdentity(state, input);
545
640
  if (excluded.has(identity) || physicalSeen.has(identity))
546
641
  return;
@@ -784,11 +879,22 @@ function stripQuery(id) {
784
879
  return query === -1 ? id : id.slice(0, query);
785
880
  }
786
881
  /**
787
- * Returns `true` for TypeScript declaration files (`.d.ts`, `.d.mts`,
788
- * `.d.cts`).
882
+ * Returns `true` for every declaration-file spelling TypeScript-Go accepts.
883
+ * Besides the standard `.d.ts`, `.d.mts`, and `.d.cts` forms, TypeScript-Go
884
+ * treats an arbitrary-extension source such as `styles.d.css.ts` as a
885
+ * declaration file too.
789
886
  */
790
887
  function isDeclarationFile(id) {
791
- return id.endsWith(".d.ts") || id.endsWith(".d.mts") || id.endsWith(".d.cts");
888
+ // Module ids can cross process/platform boundaries (for example, a Windows
889
+ // id inspected by a POSIX host). TypeScript-Go normalizes both separators
890
+ // before taking the basename, so a `.d.` directory component must not turn
891
+ // an ordinary source into a declaration file.
892
+ const normalized = id.replaceAll("\\", "/");
893
+ const base = normalized.slice(normalized.lastIndexOf("/") + 1);
894
+ return (base.endsWith(".d.ts") ||
895
+ base.endsWith(".d.mts") ||
896
+ base.endsWith(".d.cts") ||
897
+ (base.endsWith(".ts") && base.includes(".d.")));
792
898
  }
793
899
  /**
794
900
  * Returns `true` when the caller has explicitly opted out of all plugins. An
@@ -828,7 +934,11 @@ function createTransformResult(source, code) {
828
934
  function matchesCachedSource(cached, file, source, buildScoped) {
829
935
  const identities = envelopeDerivation(cached).identityContext;
830
936
  const currentKey = toProjectKey(cached.projectRoot, file, identities);
831
- if (cached.inputHashes[currentKey] !== hashText(source)) {
937
+ const identity = pathIdentityKey(file, identities);
938
+ const expected = cached.sourceHashes?.[identity] ??
939
+ cached.inputHashes[currentKey] ??
940
+ cached.externalInputHashes?.[identity];
941
+ if (expected !== hashText(source)) {
832
942
  return false;
833
943
  }
834
944
  if (buildScoped &&
@@ -884,6 +994,7 @@ function matchesNarrowPersistentInputs(cached, file) {
884
994
  file,
885
995
  projectRoot: cached.projectRoot,
886
996
  result: cached.result,
997
+ scratchDirectory: cached.scratchDirectory,
887
998
  temporaryTsconfig: cached.temporaryTsconfig,
888
999
  });
889
1000
  for (const input of inputs) {
@@ -1102,6 +1213,7 @@ function isMissingPathError(error) {
1102
1213
  function captureUniversalHostInputValidation(cached, currentFile) {
1103
1214
  const filesystem = resultFilesystem(cached.result);
1104
1215
  const state = envelopeDerivation(cached);
1216
+ const failures = createGenerationProofFailures();
1105
1217
  const validation = {
1106
1218
  entries: new Map(),
1107
1219
  covered: new Set(),
@@ -1111,6 +1223,7 @@ function captureUniversalHostInputValidation(cached, currentFile) {
1111
1223
  filesystem,
1112
1224
  projectRoot: cached.projectRoot,
1113
1225
  result: cached.result,
1226
+ scratchDirectory: cached.scratchDirectory,
1114
1227
  temporaryTsconfig: cached.temporaryTsconfig,
1115
1228
  })) {
1116
1229
  const generationHashes = cached.result.type === "exception"
@@ -1126,8 +1239,14 @@ function captureUniversalHostInputValidation(cached, currentFile) {
1126
1239
  let readable = false;
1127
1240
  if (expected === undefined) {
1128
1241
  const current = path.resolve(currentFile);
1129
- if (path.resolve(input) !== current)
1130
- return undefined;
1242
+ if (path.resolve(input) !== current) {
1243
+ recordGenerationProofFailure(failures, {
1244
+ domain: "host",
1245
+ kind: "content-proof-missing",
1246
+ path: input,
1247
+ });
1248
+ return { failures };
1249
+ }
1131
1250
  // The current module may be supplied from an unsaved editor buffer. Its
1132
1251
  // generation snapshot is overlaid below from `currentSource`, so a disk
1133
1252
  // fingerprint would be both unavailable and the wrong authority. The
@@ -1137,7 +1256,12 @@ function captureUniversalHostInputValidation(cached, currentFile) {
1137
1256
  else {
1138
1257
  const current = hostInputStateHash(input, filesystem);
1139
1258
  if (expected !== current) {
1140
- return undefined;
1259
+ recordGenerationProofFailure(failures, {
1260
+ domain: "host",
1261
+ kind: "content-changed",
1262
+ path: input,
1263
+ });
1264
+ return { failures };
1141
1265
  }
1142
1266
  // A path both sides agree they could not read carries no bytes for a
1143
1267
  // signature to stand for. It still belongs in the manifest, so the
@@ -1148,16 +1272,35 @@ function captureUniversalHostInputValidation(cached, currentFile) {
1148
1272
  if (generationRealpaths !== undefined) {
1149
1273
  if (!Object.prototype.hasOwnProperty.call(generationRealpaths, absoluteInput) ||
1150
1274
  !sameHostInputRealpath(generationRealpaths[absoluteInput], hostInputRealpath(input, filesystem), state.identityContext)) {
1151
- return undefined;
1275
+ recordGenerationProofFailure(failures, {
1276
+ domain: "host",
1277
+ kind: Object.prototype.hasOwnProperty.call(generationRealpaths, absoluteInput)
1278
+ ? "realpath-changed"
1279
+ : "realpath-proof-missing",
1280
+ path: input,
1281
+ });
1282
+ return { failures };
1152
1283
  }
1153
1284
  }
1154
1285
  validation.covered.add(path.resolve(input));
1155
1286
  const before = inputMetadataEvidence(input, filesystem);
1156
- if (!matchesRecordedInput(cached, input))
1157
- return undefined;
1287
+ if (!matchesRecordedInput(cached, input)) {
1288
+ recordGenerationProofFailure(failures, {
1289
+ domain: "host",
1290
+ kind: "snapshot-mismatch",
1291
+ path: input,
1292
+ });
1293
+ return { failures };
1294
+ }
1158
1295
  const after = inputMetadataSignature(input, filesystem);
1159
- if (before?.signature !== after)
1160
- return undefined;
1296
+ if (before?.signature !== after) {
1297
+ recordGenerationProofFailure(failures, {
1298
+ domain: "host",
1299
+ kind: "changed-during-validation",
1300
+ path: input,
1301
+ });
1302
+ return { failures };
1303
+ }
1161
1304
  if (before !== undefined) {
1162
1305
  // Do not key this manifest by physical identity. A symlink/junction
1163
1306
  // spelling and its selected target deliberately share that identity,
@@ -1177,8 +1320,14 @@ function captureUniversalHostInputValidation(cached, currentFile) {
1177
1320
  const probe = missingPathProbe(input, filesystem);
1178
1321
  if (probe.blocker !== undefined) {
1179
1322
  const signature = inputMetadataSignature(probe.blocker, filesystem);
1180
- if (signature === undefined)
1181
- return undefined;
1323
+ if (signature === undefined) {
1324
+ recordGenerationProofFailure(failures, {
1325
+ domain: "host",
1326
+ kind: "blocker-metadata-unavailable",
1327
+ path: probe.blocker,
1328
+ });
1329
+ return { failures };
1330
+ }
1182
1331
  // A blocker proves a kind and an identity, not content: it is the
1183
1332
  // non-directory ancestor that makes everything below it unreachable, and
1184
1333
  // it cannot stop being that without its metadata moving. So it keeps a
@@ -1205,7 +1354,7 @@ function captureUniversalHostInputValidation(cached, currentFile) {
1205
1354
  names.add(normalizeHostInputName(probe.name, state.identityContext.caseSensitive(probe.directory)));
1206
1355
  }
1207
1356
  cached.hostInputValidation = validation;
1208
- return validation;
1357
+ return { failures, validation };
1209
1358
  }
1210
1359
  /**
1211
1360
  * The recorded state of an input the generation read nothing from: absent, or
@@ -1510,7 +1659,9 @@ function matchesCompleteInputSnapshot(cached, currentKey, source) {
1510
1659
  if (!sameProjectDirectories(cached.projectDirectories, current.projectDirectories)) {
1511
1660
  return false;
1512
1661
  }
1513
- current.hashes[currentKey] = hashText(source);
1662
+ if (Object.prototype.hasOwnProperty.call(cached.inputHashes, currentKey)) {
1663
+ current.hashes[currentKey] = hashText(source);
1664
+ }
1514
1665
  if (!sameHashes(cached.inputHashes, current.hashes, declaredInputs)) {
1515
1666
  return false;
1516
1667
  }
@@ -1578,18 +1729,30 @@ function matchesExternalInputRealpaths(cached) {
1578
1729
  }
1579
1730
  /**
1580
1731
  * Capture external-input hashes without attaching post-compile state to an
1581
- * earlier graph. Graph members must carry compiler-time proof and still match
1582
- * it now; plugin-declared dependency-only paths retain the historical
1583
- * post-compile snapshot because their own protocol does not claim generation
1584
- * fingerprints.
1732
+ * earlier graph. Graph members and out-of-walk transformed sources must carry
1733
+ * compiler-time proof and still match it now; plugin-declared dependency-only
1734
+ * paths retain the historical post-compile snapshot because their own protocol
1735
+ * does not claim generation fingerprints.
1585
1736
  */
1586
1737
  function captureExternalInputSnapshot(cached, paths) {
1587
1738
  const state = envelopeDerivation(cached);
1588
1739
  const filesystem = resultFilesystem(cached.result);
1589
1740
  const graph = envelopeGraphIndexes(state, cached);
1741
+ // A non-declaration transform output is a compiler-realized source even when
1742
+ // a malformed or legacy graph omitted its node. Its output was computed from
1743
+ // compiler-time bytes, so a post-compile host read cannot prove coherence.
1744
+ const transformSources = new Set();
1745
+ if (cached.result.type === "success") {
1746
+ for (const output of Object.keys(cached.result.typescript)) {
1747
+ if (!isDeclarationFile(output)) {
1748
+ transformSources.add(derivationIdentity(state, path.resolve(cached.projectRoot, output)));
1749
+ }
1750
+ }
1751
+ }
1590
1752
  const hashes = {};
1591
1753
  const realpaths = {};
1592
1754
  const signatures = {};
1755
+ const failures = createGenerationProofFailures();
1593
1756
  let complete = true;
1594
1757
  // Sandwich every read between two metadata signatures. Only a signature that
1595
1758
  // survived its own read, and whose stamp's tick the filesystem's clock has
@@ -1607,21 +1770,47 @@ function captureExternalInputSnapshot(cached, paths) {
1607
1770
  // through to the recorded-state branch below, the same evidence a
1608
1771
  // plugin-declared dependency path carries. Its absence still invalidates
1609
1772
  // the generation when it appears, because `missing` is recorded state.
1610
- const speculativeOnly = graph.speculative.has(identity) &&
1773
+ const realizedTransformSource = transformSources.has(identity);
1774
+ const speculativeOnly = !realizedTransformSource &&
1775
+ graph.speculative.has(identity) &&
1611
1776
  !graph.inputProofs.has(identity) &&
1612
1777
  !graph.inputProofConflicts.has(identity);
1613
- if (graph.members.has(identity) && !speculativeOnly) {
1778
+ if ((realizedTransformSource || graph.members.has(identity)) &&
1779
+ !speculativeOnly) {
1614
1780
  const proof = graph.inputProofs.get(identity);
1615
1781
  if (proof === undefined || graph.inputProofConflicts.has(identity)) {
1616
1782
  complete = false;
1783
+ recordGenerationProofFailure(failures, {
1784
+ domain: "external",
1785
+ kind: graph.inputProofConflicts.has(identity)
1786
+ ? "graph-proof-conflict"
1787
+ : "graph-proof-missing",
1788
+ detail: graph.inputProofFailures.get(identity),
1789
+ path: input,
1790
+ });
1617
1791
  continue;
1618
1792
  }
1619
1793
  const before = inputMetadataEvidence(input, filesystem);
1620
1794
  const currentHash = graphInputStateHash(input, filesystem);
1795
+ const currentRealpath = hostInputRealpath(input, filesystem);
1621
1796
  const after = inputMetadataSignature(input, filesystem);
1622
- if (currentHash !== proof.hash ||
1623
- !sameHostInputRealpath(proof.realpath, hostInputRealpath(input, filesystem), state.identityContext)) {
1797
+ const realpathMatches = sameHostInputRealpath(proof.realpath, currentRealpath, state.identityContext);
1798
+ if (currentHash !== proof.hash || !realpathMatches) {
1624
1799
  complete = false;
1800
+ if (currentHash !== proof.hash) {
1801
+ recordGenerationProofFailure(failures, {
1802
+ domain: "external",
1803
+ kind: "graph-content-changed",
1804
+ path: input,
1805
+ });
1806
+ }
1807
+ if (!realpathMatches) {
1808
+ recordGenerationProofFailure(failures, {
1809
+ domain: "external",
1810
+ kind: "graph-realpath-changed",
1811
+ path: input,
1812
+ });
1813
+ }
1625
1814
  }
1626
1815
  else if (currentHash !== null) {
1627
1816
  // The recorded hash is the compiler's own proof, so a signature may
@@ -1641,27 +1830,30 @@ function captureExternalInputSnapshot(cached, paths) {
1641
1830
  if (hash !== null)
1642
1831
  record(input, before, after);
1643
1832
  }
1644
- return { complete, hashes, realpaths, signatures };
1833
+ return { complete, failures, hashes, realpaths, signatures };
1645
1834
  }
1646
- /** Verify every graph member still has the state read by the compiler. */
1647
- function matchesCompilerGraphInputProofs(cached) {
1835
+ /** Explain every graph member that no longer matches the compiler's state. */
1836
+ function compilerGraphInputProofFailures(cached) {
1837
+ const failures = createGenerationProofFailures();
1648
1838
  if (cached.result.type === "exception" ||
1649
1839
  cached.result.graph === undefined ||
1650
1840
  (cached.result.graph.inputHashes === undefined &&
1651
- cached.result.graph.inputRealpaths === undefined)) {
1841
+ cached.result.graph.inputRealpaths === undefined &&
1842
+ cached.result.graph.inputProofFailures === undefined)) {
1652
1843
  // Legacy sidecars remain compatible for ordinary in-project graphs. Their
1653
1844
  // out-of-walk members are still rejected by captureExternalInputSnapshot,
1654
1845
  // where a post-compile snapshot cannot prove the compiler's generation.
1655
- return true;
1846
+ return failures;
1656
1847
  }
1657
1848
  const state = envelopeDerivation(cached);
1658
1849
  const filesystem = resultFilesystem(cached.result);
1659
1850
  const graph = envelopeGraphIndexes(state, cached);
1660
- if (graph.inputProofConflicts.size !== 0) {
1661
- return false;
1662
- }
1663
1851
  for (const identity of graph.members) {
1664
1852
  const proof = graph.inputProofs.get(identity);
1853
+ const spelling = proof?.path ?? graph.spellings.get(identity) ?? cached.projectRoot;
1854
+ if (isTransformScratchInput(spelling, cached.scratchDirectory)) {
1855
+ continue;
1856
+ }
1665
1857
  // A speculative candidate has no compile-time read to prove. Requiring one
1666
1858
  // would void every generation of every project whose resolution passes over
1667
1859
  // a higher-priority spelling, which is every project with a dependency
@@ -1670,13 +1862,41 @@ function matchesCompilerGraphInputProofs(cached) {
1670
1862
  if (proof === undefined && graph.speculative.has(identity)) {
1671
1863
  continue;
1672
1864
  }
1673
- if (proof === undefined ||
1674
- graphInputStateHash(proof.path, filesystem) !== proof.hash ||
1675
- !sameHostInputRealpath(proof.realpath, hostInputRealpath(proof.path, filesystem), state.identityContext)) {
1676
- return false;
1865
+ if (graph.inputProofConflicts.has(identity)) {
1866
+ recordGenerationProofFailure(failures, {
1867
+ domain: "graph",
1868
+ kind: "proof-conflict",
1869
+ detail: graph.inputProofFailures.get(identity),
1870
+ path: spelling,
1871
+ });
1872
+ continue;
1873
+ }
1874
+ if (proof === undefined) {
1875
+ recordGenerationProofFailure(failures, {
1876
+ domain: "graph",
1877
+ kind: "proof-missing",
1878
+ detail: graph.inputProofFailures.get(identity),
1879
+ path: spelling,
1880
+ });
1881
+ continue;
1882
+ }
1883
+ const currentHash = graphInputStateHash(proof.path, filesystem);
1884
+ if (currentHash !== proof.hash) {
1885
+ recordGenerationProofFailure(failures, {
1886
+ domain: "graph",
1887
+ kind: "content-changed",
1888
+ path: proof.path,
1889
+ });
1890
+ }
1891
+ if (!sameHostInputRealpath(proof.realpath, hostInputRealpath(proof.path, filesystem), state.identityContext)) {
1892
+ recordGenerationProofFailure(failures, {
1893
+ domain: "graph",
1894
+ kind: "realpath-changed",
1895
+ path: proof.path,
1896
+ });
1677
1897
  }
1678
1898
  }
1679
- return true;
1899
+ return failures;
1680
1900
  }
1681
1901
  /** Compare one derived input with the snapshot that owned it at generation. */
1682
1902
  function matchesRecordedInput(cached, input) {
@@ -1735,6 +1955,7 @@ function collectProjectInputSnapshot(projectRoot, identities, filesystem = DEFAU
1735
1955
  const unstableFiles = new Set();
1736
1956
  let attributed = true;
1737
1957
  const walked = walkProjectInputs(projectRoot, filesystem);
1958
+ const walkFailures = [...walked.failures];
1738
1959
  let complete = walked.complete;
1739
1960
  for (const file of walked.files) {
1740
1961
  try {
@@ -1762,6 +1983,7 @@ function collectProjectInputSnapshot(projectRoot, identities, filesystem = DEFAU
1762
1983
  before.signature !== after) {
1763
1984
  complete = false;
1764
1985
  unstableFiles.add(key);
1986
+ walkFailures.push({ kind: "file-changed-during-read", path: file });
1765
1987
  }
1766
1988
  else {
1767
1989
  fileSignatures[key] = after;
@@ -1778,6 +2000,7 @@ function collectProjectInputSnapshot(projectRoot, identities, filesystem = DEFAU
1778
2000
  // File watchers may observe a transform while another process is moving
1779
2001
  // or deleting files. The missing key invalidates older cache entries.
1780
2002
  complete = false;
2003
+ walkFailures.push({ kind: "file-read-failed", path: file });
1781
2004
  try {
1782
2005
  unstableFiles.add(toProjectKey(projectRoot, file, identities));
1783
2006
  }
@@ -1796,6 +2019,7 @@ function collectProjectInputSnapshot(projectRoot, identities, filesystem = DEFAU
1796
2019
  projectDirectories: walked.directories,
1797
2020
  provenSignatures,
1798
2021
  unstableFiles,
2022
+ walkFailures,
1799
2023
  };
1800
2024
  }
1801
2025
  /**
@@ -1809,6 +2033,7 @@ function collectProjectInputSnapshot(projectRoot, identities, filesystem = DEFAU
1809
2033
  function walkProjectInputs(root, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1810
2034
  let complete = true;
1811
2035
  const directories = [];
2036
+ const failures = [];
1812
2037
  const files = [];
1813
2038
  const stack = [root];
1814
2039
  while (stack.length !== 0) {
@@ -1816,6 +2041,10 @@ function walkProjectInputs(root, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1816
2041
  const before = projectDirectorySignature(current, filesystem);
1817
2042
  if (before === undefined) {
1818
2043
  complete = false;
2044
+ failures.push({
2045
+ kind: "directory-metadata-unavailable",
2046
+ path: current,
2047
+ });
1819
2048
  continue;
1820
2049
  }
1821
2050
  let entries;
@@ -1824,11 +2053,18 @@ function walkProjectInputs(root, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1824
2053
  }
1825
2054
  catch {
1826
2055
  complete = false;
2056
+ failures.push({ kind: "directory-read-failed", path: current });
1827
2057
  continue;
1828
2058
  }
1829
2059
  const after = projectDirectorySignature(current, filesystem);
1830
2060
  if (after === undefined || before !== after) {
1831
2061
  complete = false;
2062
+ failures.push({
2063
+ kind: after === undefined
2064
+ ? "directory-metadata-unavailable"
2065
+ : "directory-changed-during-walk",
2066
+ path: current,
2067
+ });
1832
2068
  }
1833
2069
  directories.push({
1834
2070
  path: current,
@@ -1854,7 +2090,7 @@ function walkProjectInputs(root, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1854
2090
  }
1855
2091
  directories.sort((left, right) => left.path.localeCompare(right.path));
1856
2092
  files.sort();
1857
- return { complete, directories, files };
2093
+ return { complete, directories, failures, files };
1858
2094
  }
1859
2095
  /** Return a cheap identity for one directory's immediate membership. */
1860
2096
  function projectDirectorySignature(directory, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
@@ -1902,6 +2138,8 @@ function openDirectoryWatch(filesystem, directory, listener, onError) {
1902
2138
  /** Watch every walked directory for membership changes after generation. */
1903
2139
  async function createProjectMutationTracker(directories, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1904
2140
  const tracker = {
2141
+ changes: new Set(),
2142
+ changesOmitted: false,
1905
2143
  close: () => undefined,
1906
2144
  failed: false,
1907
2145
  membershipChanged: false,
@@ -1918,9 +2156,12 @@ async function createProjectMutationTracker(directories, filesystem = DEFAULT_FI
1918
2156
  };
1919
2157
  for (const directory of directories) {
1920
2158
  try {
1921
- watchers.push(openDirectoryWatch(filesystem, directory.path, (eventType) => {
1922
- if (eventType === "rename")
1923
- tracker.membershipChanged = true;
2159
+ watchers.push(openDirectoryWatch(filesystem, directory.path, (eventType, filename) => {
2160
+ if (eventType === "rename") {
2161
+ recordProjectMutation(tracker, filename === null
2162
+ ? directory.path
2163
+ : path.join(directory.path, filename));
2164
+ }
1924
2165
  }, () => {
1925
2166
  tracker.failed = true;
1926
2167
  }));
@@ -1956,6 +2197,8 @@ async function createHostInputMutationTracker(inputs, filesystem, covered, event
1956
2197
  names: [...location.names],
1957
2198
  }));
1958
2199
  const tracker = {
2200
+ changes: new Set(),
2201
+ changesOmitted: false,
1959
2202
  close: () => undefined,
1960
2203
  // Coverage is the caller's claim, and it is required rather than derived
1961
2204
  // from the input list: an input is watched by its exact name here, but only
@@ -1989,7 +2232,9 @@ async function createHostInputMutationTracker(inputs, filesystem, covered, event
1989
2232
  ? null
1990
2233
  : normalizeHostInputName(filename, caseSensitive);
1991
2234
  if (reported === null || names.has(reported)) {
1992
- tracker.membershipChanged = true;
2235
+ recordProjectMutation(tracker, filename === null
2236
+ ? location.directory
2237
+ : path.join(location.directory, filename));
1993
2238
  }
1994
2239
  }, () => {
1995
2240
  tracker.failed = true;
@@ -2001,6 +2246,18 @@ async function createHostInputMutationTracker(inputs, filesystem, covered, event
2001
2246
  }
2002
2247
  return tracker;
2003
2248
  }
2249
+ /** Record enough exact mutation evidence without retaining an event stream. */
2250
+ function recordProjectMutation(tracker, changed) {
2251
+ tracker.membershipChanged = true;
2252
+ if (tracker.changes.has(changed))
2253
+ return;
2254
+ if (tracker.changes.size < MAX_GENERATION_MUTATION_PATHS) {
2255
+ tracker.changes.add(changed);
2256
+ }
2257
+ else {
2258
+ tracker.changesOmitted = true;
2259
+ }
2260
+ }
2004
2261
  let windowsProjectMutationBroker;
2005
2262
  /**
2006
2263
  * Register directory watches in an isolated Windows process.
@@ -2126,7 +2383,14 @@ function getWindowsProjectMutationBroker() {
2126
2383
  if (record.ready === true)
2127
2384
  registration.ready();
2128
2385
  if (record.ready !== true && record.failed !== true) {
2129
- registration.tracker.membershipChanged = true;
2386
+ if (typeof record.directory === "string") {
2387
+ recordProjectMutation(registration.tracker, typeof record.filename === "string"
2388
+ ? path.join(record.directory, record.filename)
2389
+ : record.directory);
2390
+ }
2391
+ else {
2392
+ registration.tracker.membershipChanged = true;
2393
+ }
2130
2394
  }
2131
2395
  });
2132
2396
  windowsProjectMutationBroker = broker;
@@ -2210,7 +2474,7 @@ const WINDOWS_WATCH_BROKER_SOURCE = [
2210
2474
  " const names = location.names === undefined ? undefined : new Set(location.names.map((name) => name.toLowerCase()));",
2211
2475
  " const watcher = fs.watch(location.directory, { persistent: false }, (event, filename) => {",
2212
2476
  " const matches = names === undefined || filename === null || names.has(String(filename).toLowerCase());",
2213
- ' if (matches && (message.allEvents || event === "rename")) process.send?.({ id: message.id });',
2477
+ ' if (matches && (message.allEvents || event === "rename")) process.send?.({ directory: location.directory, filename: filename === null ? null : String(filename), id: message.id });',
2214
2478
  " });",
2215
2479
  ' watcher.on("error", () => process.send?.({ failed: true, id: message.id }));',
2216
2480
  " watchers.push(watcher);",
@@ -2417,12 +2681,13 @@ function matchesCachedExternalInputs(cached) {
2417
2681
  }
2418
2682
  /**
2419
2683
  * Derive the absolute out-of-walk input set of a whole project transform: the
2420
- * union of every reference-graph member (edge keys and targets, globals, the
2421
- * config chain) and every plugin-reported dependency, minus everything the
2422
- * project walk already hashes and the disposed temp-dir tsconfig. These are the
2423
- * inputs {@link matchesCachedSource}'s walk cannot see. Resolution candidates
2424
- * that are still missing remain in this set even under the project root: the
2425
- * first walk cannot hash a file that has not been created yet.
2684
+ * union of every transformed source key, reference-graph member (edge keys and
2685
+ * targets, globals, the config chain), and plugin-reported dependency, minus
2686
+ * everything the project walk already hashes and the disposed transform scratch
2687
+ * tree. These are the inputs {@link matchesCachedSource}'s walk cannot see.
2688
+ * Resolution candidates that are still missing remain in this set even under
2689
+ * the project root: the first walk cannot hash a file that has not been created
2690
+ * yet.
2426
2691
  *
2427
2692
  * A `dependenciesComplete` declaration deliberately does not narrow the stored
2428
2693
  * set: other files in the same whole-project result can still own the omitted
@@ -2439,6 +2704,10 @@ function selectExternalInputPaths(props) {
2439
2704
  const identities = createHostPathIdentityContext(filesystem);
2440
2705
  const resolutionCandidates = new Set();
2441
2706
  const graph = props.result.graph;
2707
+ // Every transform output key names the source file whose transformed text it
2708
+ // carries. Keep an out-of-walk source in the external snapshot instead of
2709
+ // injecting it into the project-walk key universe (samchon/ttsc#252).
2710
+ members.push(...Object.keys(props.result.typescript));
2442
2711
  if (graph !== undefined) {
2443
2712
  for (const [source, targets] of Object.entries(graph.edges ?? {})) {
2444
2713
  members.push(source);
@@ -2495,6 +2764,7 @@ function selectExternalInputPaths(props) {
2495
2764
  const identity = pathIdentityKey(absolute, identities);
2496
2765
  const missingCandidate = resolutionCandidates.has(identity) && !filesystem.exists(absolute);
2497
2766
  if (identity === excluded ||
2767
+ isTransformScratchInput(absolute, props.scratchDirectory) ||
2498
2768
  seen.has(spelling) ||
2499
2769
  (!missingCandidate &&
2500
2770
  isProjectWalkPath(props.projectRoot, absolute, identities, filesystem))) {
@@ -2558,6 +2828,7 @@ function selectNotifiableAbsentInputs(props) {
2558
2828
  const absolute = path.resolve(props.projectRoot, candidate);
2559
2829
  const spelling = path.resolve(absolute);
2560
2830
  if (seen.has(spelling) ||
2831
+ isTransformScratchInput(absolute, props.scratchDirectory) ||
2561
2832
  (excluded !== undefined &&
2562
2833
  pathIdentityKey(absolute, identities) === excluded) ||
2563
2834
  props.filesystem.exists(absolute)) {
@@ -2730,6 +3001,103 @@ function walkSnapshotComplete(snapshot, declared) {
2730
3001
  }
2731
3002
  return true;
2732
3003
  }
3004
+ /** Preserve exact project-walk and mutation witnesses for one failed attempt. */
3005
+ function recordProjectSnapshotFailures(failures, props) {
3006
+ const recordWalk = (snapshot) => {
3007
+ for (const failure of snapshot.walkFailures) {
3008
+ if (failure.kind.startsWith("file-") && props.declared !== undefined) {
3009
+ try {
3010
+ const key = toProjectKey(props.projectRoot, failure.path, props.identities);
3011
+ if (!props.declared.has(key))
3012
+ continue;
3013
+ }
3014
+ catch {
3015
+ // An unidentifiable failed input taints the complete project walk.
3016
+ }
3017
+ }
3018
+ recordGenerationProofFailure(failures, {
3019
+ domain: "project",
3020
+ kind: failure.kind,
3021
+ path: failure.path,
3022
+ });
3023
+ }
3024
+ };
3025
+ recordWalk(props.before);
3026
+ recordWalk(props.snapshot);
3027
+ const keys = props.declared ??
3028
+ new Set([
3029
+ ...Object.keys(props.before.hashes),
3030
+ ...Object.keys(props.snapshot.hashes),
3031
+ ]);
3032
+ for (const key of keys) {
3033
+ if (props.before.hashes[key] !== props.snapshot.hashes[key]) {
3034
+ recordGenerationProofFailure(failures, {
3035
+ domain: "project",
3036
+ kind: "input-content-changed",
3037
+ path: path.resolve(props.projectRoot, key),
3038
+ });
3039
+ }
3040
+ if (props.before.fileSignatures[key] !== props.snapshot.fileSignatures[key]) {
3041
+ recordGenerationProofFailure(failures, {
3042
+ domain: "project",
3043
+ kind: "input-metadata-changed",
3044
+ path: path.resolve(props.projectRoot, key),
3045
+ });
3046
+ }
3047
+ }
3048
+ const leftDirectories = new Map(props.before.projectDirectories.map((entry) => [
3049
+ entry.path,
3050
+ entry.signature,
3051
+ ]));
3052
+ const rightDirectories = new Map(props.snapshot.projectDirectories.map((entry) => [
3053
+ entry.path,
3054
+ entry.signature,
3055
+ ]));
3056
+ for (const directory of new Set([
3057
+ ...leftDirectories.keys(),
3058
+ ...rightDirectories.keys(),
3059
+ ])) {
3060
+ if (leftDirectories.get(directory) !== rightDirectories.get(directory)) {
3061
+ recordGenerationProofFailure(failures, {
3062
+ domain: "project",
3063
+ kind: "directory-membership-changed",
3064
+ path: directory,
3065
+ });
3066
+ }
3067
+ }
3068
+ const recordTracker = (tracker, kind) => {
3069
+ if (tracker?.membershipChanged !== true)
3070
+ return;
3071
+ if (tracker.changes.size === 0) {
3072
+ recordGenerationProofFailure(failures, {
3073
+ domain: "project",
3074
+ kind,
3075
+ path: props.projectRoot,
3076
+ });
3077
+ return;
3078
+ }
3079
+ for (const changed of tracker.changes) {
3080
+ recordGenerationProofFailure(failures, {
3081
+ domain: "project",
3082
+ kind,
3083
+ path: changed,
3084
+ });
3085
+ }
3086
+ if (tracker.changesOmitted) {
3087
+ failures.omitted = Math.min(Number.MAX_SAFE_INTEGER, failures.omitted + 1);
3088
+ }
3089
+ };
3090
+ recordTracker(props.tracker, "project-membership-event");
3091
+ recordTracker(props.hostInputTracker, "host-input-event");
3092
+ recordTracker(props.candidateTracker, "candidate-event");
3093
+ if (failures.entries.length === 0) {
3094
+ recordGenerationProofFailure(failures, {
3095
+ domain: "project",
3096
+ kind: "snapshot-incomplete",
3097
+ path: props.projectRoot,
3098
+ });
3099
+ }
3100
+ }
2733
3101
  /** {@link selectDeclaredProjectInputKeys} memoized per envelope generation. */
2734
3102
  function declaredProjectInputKeys(state, cached) {
2735
3103
  if (state.declaredInputKeysBuilt !== true) {
@@ -2737,6 +3105,7 @@ function declaredProjectInputKeys(state, cached) {
2737
3105
  identities: state.identityContext,
2738
3106
  projectRoot: cached.projectRoot,
2739
3107
  result: cached.result,
3108
+ scratchDirectory: cached.scratchDirectory,
2740
3109
  });
2741
3110
  state.declaredInputKeysBuilt = true;
2742
3111
  }
@@ -2757,7 +3126,10 @@ function selectDeclaredProjectInputKeys(props) {
2757
3126
  const add = (entry) => {
2758
3127
  if (typeof entry !== "string" || entry.length === 0)
2759
3128
  return;
2760
- keys.add(toProjectKey(props.projectRoot, path.resolve(props.projectRoot, entry), props.identities));
3129
+ const absolute = path.resolve(props.projectRoot, entry);
3130
+ if (isTransformScratchInput(absolute, props.scratchDirectory))
3131
+ return;
3132
+ keys.add(toProjectKey(props.projectRoot, absolute, props.identities));
2761
3133
  };
2762
3134
  for (const [source, targets] of Object.entries(graph.edges ?? {})) {
2763
3135
  add(source);
@@ -2789,46 +3161,231 @@ function selectDeclaredProjectInputKeys(props) {
2789
3161
  }
2790
3162
  return keys;
2791
3163
  }
3164
+ /** Create an empty bounded witness collection for one transform attempt. */
3165
+ function createGenerationProofFailures() {
3166
+ return { entries: [], omitted: 0, seen: new Set() };
3167
+ }
3168
+ /** Retain one unique proof witness without allowing diagnostics to grow freely. */
3169
+ function recordGenerationProofFailure(failures, failure) {
3170
+ const key = JSON.stringify([
3171
+ failure.domain,
3172
+ failure.kind,
3173
+ failure.path,
3174
+ failure.detail,
3175
+ ]);
3176
+ if (failures.seen.has(key))
3177
+ return;
3178
+ if (failures.entries.length < MAX_GENERATION_PROOF_FAILURES) {
3179
+ // `seen` follows the same bound as `entries`: retaining every discarded
3180
+ // identity would make a bounded diagnostic an unbounded memory sink.
3181
+ failures.seen.add(key);
3182
+ failures.entries.push(failure);
3183
+ }
3184
+ else {
3185
+ failures.omitted = Math.min(Number.MAX_SAFE_INTEGER, failures.omitted + 1);
3186
+ }
3187
+ }
3188
+ /** Fold one bounded witness collection into another. */
3189
+ function mergeGenerationProofFailures(target, source) {
3190
+ for (const failure of source.entries) {
3191
+ recordGenerationProofFailure(target, failure);
3192
+ }
3193
+ target.omitted = Math.min(Number.MAX_SAFE_INTEGER, target.omitted + source.omitted);
3194
+ }
3195
+ /** Hash the declared-input-relevant failure shape without retaining it. */
3196
+ function projectWalkFailureFingerprint(snapshot, declared, projectRoot, identities) {
3197
+ const relevantUnstableFiles = declared === undefined
3198
+ ? [...snapshot.unstableFiles]
3199
+ : [...snapshot.unstableFiles].filter((key) => declared.has(key));
3200
+ const relevantFailures = snapshot.walkFailures.filter((failure) => {
3201
+ if (!failure.kind.startsWith("file-"))
3202
+ return true;
3203
+ if (declared === undefined)
3204
+ return true;
3205
+ try {
3206
+ return declared.has(toProjectKey(projectRoot, failure.path, identities));
3207
+ }
3208
+ catch {
3209
+ return true;
3210
+ }
3211
+ });
3212
+ return hashText(JSON.stringify({
3213
+ complete: walkSnapshotComplete(snapshot, declared),
3214
+ directoryComplete: snapshot.directoryComplete,
3215
+ failures: relevantFailures
3216
+ .map((failure) => `${failure.kind}\0${path.resolve(failure.path)}`)
3217
+ .sort(),
3218
+ unstableFiles: relevantUnstableFiles.sort(),
3219
+ }));
3220
+ }
3221
+ /** Compact state of one exact out-of-walk input in a failed generation. */
3222
+ function failedGenerationInputState(input, filesystem) {
3223
+ let directory = "not-directory";
3224
+ try {
3225
+ if (filesystem.stat(input).isDirectory()) {
3226
+ directory = hashText(filesystem
3227
+ .readdir(input)
3228
+ .map((entry) => [
3229
+ entry.name,
3230
+ entry.isDirectory(),
3231
+ entry.isFile(),
3232
+ entry.isSymbolicLink(),
3233
+ ].join(":"))
3234
+ .sort()
3235
+ .join("\0"));
3236
+ }
3237
+ }
3238
+ catch {
3239
+ directory = "unavailable";
3240
+ }
3241
+ return hashText(JSON.stringify([
3242
+ inputMetadataSignature(input, filesystem) ?? "missing",
3243
+ hostInputStateHash(input, filesystem) ?? MISSING_INPUT_STATE,
3244
+ hostInputRealpath(input, filesystem),
3245
+ directory,
3246
+ ]));
3247
+ }
3248
+ /** Snapshot every input outside the project walk that could change a retry. */
3249
+ function captureFailedGenerationInputStates(cached, failures) {
3250
+ const filesystem = resultFilesystem(cached.result);
3251
+ const inputs = new Set((cached.externalInputPaths ?? []).map((input) => path.resolve(input)));
3252
+ for (const input of selectPersistentHostInputs({
3253
+ filesystem,
3254
+ projectRoot: cached.projectRoot,
3255
+ result: cached.result,
3256
+ scratchDirectory: cached.scratchDirectory,
3257
+ temporaryTsconfig: cached.temporaryTsconfig,
3258
+ })) {
3259
+ inputs.add(path.resolve(input));
3260
+ }
3261
+ for (const failure of failures.entries) {
3262
+ if (failure.path !== undefined)
3263
+ inputs.add(path.resolve(failure.path));
3264
+ }
3265
+ return new Map([...inputs]
3266
+ .sort()
3267
+ .map((input) => [input, failedGenerationInputState(input, filesystem)]));
3268
+ }
3269
+ /** Capture source baselines for project and out-of-walk transform outputs. */
3270
+ function captureTransformSourceHashes(cached, currentFile, currentSourceHash) {
3271
+ const filesystem = resultFilesystem(cached.result);
3272
+ const identities = envelopeDerivation(cached).identityContext;
3273
+ const hashes = {};
3274
+ if (cached.result.type === "success") {
3275
+ for (const output of Object.keys(cached.result.typescript)) {
3276
+ const file = path.resolve(cached.projectRoot, output);
3277
+ const hash = hostInputStateHash(file, filesystem);
3278
+ if (hash !== null)
3279
+ hashes[pathIdentityKey(file, identities)] = hash;
3280
+ }
3281
+ }
3282
+ hashes[pathIdentityKey(currentFile, identities)] = currentSourceHash;
3283
+ return hashes;
3284
+ }
2792
3285
  /**
2793
- * Project roots already told they cannot reuse a compile, so a build reports
2794
- * the condition once instead of once per module.
2795
- */
2796
- const REPORTED_UNREUSABLE_GENERATIONS = new Set();
2797
- /**
2798
- * Report, once per project root, that a generation cannot be reused.
3286
+ * Whether a terminal proof failure's observed environment actually changed.
2799
3287
  *
2800
- * Every module of the build then recompiles the whole project, so the condition
2801
- * is the difference between one compile and one compile per module. It stayed
2802
- * invisible for the whole life of samchon/ttsc#970: consumers saw only a build
2803
- * that never finished, and each investigation had to rediscover the cause from
2804
- * outside. A named reason turns the next occurrence into a bug report instead
2805
- * of an archaeology session.
2806
- */
2807
- function reportUnreusableGeneration(cached, evidence) {
2808
- const missing = [
2809
- ...(evidence.walkStable ? [] : ["a stable project snapshot"]),
2810
- ...(evidence.graphProofs ? [] : ["compiler proofs for its graph inputs"]),
2811
- ...(evidence.externalInputs
2812
- ? []
2813
- : ["a complete out-of-walk input snapshot"]),
2814
- ...(evidence.universalInputs ? [] : ["a universal host-input manifest"]),
2815
- ];
2816
- const key = `${cached.projectRoot}\0${missing.join(",")}`;
2817
- if (REPORTED_UNREUSABLE_GENERATIONS.has(key)) {
2818
- return;
3288
+ * This is deliberately a confirmation test: inability to re-probe retains the
3289
+ * old verdict instead of turning every module request into another compile.
3290
+ * Cache lifecycle reset remains the unconditional recovery boundary.
3291
+ */
3292
+ function failedGenerationEnvironmentChanged(validation, props) {
3293
+ try {
3294
+ const identities = envelopeDerivation(validation.cached).identityContext;
3295
+ const currentSourceHash = hashText(props.currentSource);
3296
+ const expectedSourceHash = validation.cached.sourceHashes?.[pathIdentityKey(props.currentFile, identities)];
3297
+ if (expectedSourceHash !== undefined &&
3298
+ expectedSourceHash !== currentSourceHash) {
3299
+ return true;
3300
+ }
3301
+ const current = collectProjectInputSnapshot(validation.cached.projectRoot, identities, props.filesystem);
3302
+ if (validation.projectWalkComplete !==
3303
+ walkSnapshotComplete(current, validation.declaredInputs) ||
3304
+ validation.projectWalkFailures !==
3305
+ projectWalkFailureFingerprint(current, validation.declaredInputs, validation.cached.projectRoot, identities) ||
3306
+ !sameHashes(validation.projectInputHashes, current.hashes, validation.declaredInputs) ||
3307
+ !sameProjectDirectories(validation.cached.projectDirectories ?? [], current.projectDirectories)) {
3308
+ return true;
3309
+ }
3310
+ for (const [input, recorded] of validation.inputStates) {
3311
+ if (failedGenerationInputState(input, props.filesystem) !== recorded) {
3312
+ return true;
3313
+ }
3314
+ }
3315
+ return false;
3316
+ }
3317
+ catch {
3318
+ return false;
2819
3319
  }
2820
- REPORTED_UNREUSABLE_GENERATIONS.add(key);
2821
- process.stderr.write(`ttsc: the transform cache cannot reuse this project's compile, so every ` +
2822
- `module recompiles the whole project.\n` +
2823
- ` project: ${cached.projectRoot}\n` +
2824
- ` missing: ${missing.join("; ")}\n` +
2825
- ` Please report this at https://github.com/samchon/ttsc/issues with ` +
2826
- `this message.\n`);
3320
+ }
3321
+ /** Render one input without leaking source content or control characters. */
3322
+ function formatGenerationFailurePath(projectRoot, input) {
3323
+ const absolute = path.resolve(input);
3324
+ const relative = path.relative(projectRoot, absolute);
3325
+ const display = relative === ""
3326
+ ? "."
3327
+ : relative !== ".." &&
3328
+ !relative.startsWith(`..${path.sep}`) &&
3329
+ !path.isAbsolute(relative)
3330
+ ? relative
3331
+ : absolute;
3332
+ return JSON.stringify(display.split(path.sep).join("/"));
3333
+ }
3334
+ /** Build the terminal error shared by every waiter of an unstable generation. */
3335
+ function createUnstableGenerationError(projectRoot, attempts, validation) {
3336
+ const lines = [
3337
+ `ttsc: could not capture a reusable transform generation after ${attempts.length} attempts.`,
3338
+ ` project: ${projectRoot}`,
3339
+ ];
3340
+ attempts.forEach((failures, index) => {
3341
+ lines.push(` attempt ${index + 1}:`);
3342
+ if (failures.entries.length === 0) {
3343
+ lines.push(" - project/generation-proof-incomplete");
3344
+ }
3345
+ for (const failure of failures.entries) {
3346
+ const input = failure.path === undefined
3347
+ ? ""
3348
+ : `: ${formatGenerationFailurePath(projectRoot, failure.path)}`;
3349
+ const detail = failure.detail === undefined
3350
+ ? ""
3351
+ : ` (producer: ${JSON.stringify(failure.detail)})`;
3352
+ lines.push(` - ${failure.domain}/${failure.kind}${input}${detail}`);
3353
+ }
3354
+ if (failures.omitted !== 0) {
3355
+ lines.push(` - ... ${failures.omitted} additional witness(es) omitted`);
3356
+ }
3357
+ });
3358
+ lines.push(" Stop writes to the listed inputs before compilation, or fix the producer that omitted or contradicted the listed proof.");
3359
+ return new TtscUnstableGenerationError(lines.join("\n"), validation);
2827
3360
  }
2828
3361
  function hashText(input) {
2829
3362
  return crypto.createHash("sha256").update(input).digest("hex");
2830
3363
  }
2831
3364
  async function transformProject(props) {
3365
+ const attempts = [];
3366
+ for (let attempt = 0; attempt < TRANSFORM_GENERATION_ATTEMPTS; attempt += 1) {
3367
+ const cached = await captureTransformGeneration(props);
3368
+ if (!props.trackProjectMembership ||
3369
+ cached.result.type !== "success" ||
3370
+ cached.projectSnapshotComplete === true) {
3371
+ return cached;
3372
+ }
3373
+ attempts.push(TRANSFORM_GENERATION_FAILURES.get(cached.result) ??
3374
+ createGenerationProofFailures());
3375
+ if (attempt + 1 === TRANSFORM_GENERATION_ATTEMPTS) {
3376
+ const validation = TRANSFORM_FAILED_GENERATION_VALIDATIONS.get(cached.result);
3377
+ if (validation === undefined) {
3378
+ disposeCachedTransform(cached);
3379
+ throw new Error("ttsc: failed transform generation has no retry validation baseline");
3380
+ }
3381
+ throw createUnstableGenerationError(path.dirname(props.tsconfig), attempts, validation);
3382
+ }
3383
+ disposeCachedTransform(cached);
3384
+ }
3385
+ throw new Error("ttsc: transform generation retry loop did not terminate");
3386
+ }
3387
+ /** Capture one whole-project transform attempt and all of its reuse proofs. */
3388
+ async function captureTransformGeneration(props) {
2832
3389
  const projectRoot = path.dirname(props.tsconfig);
2833
3390
  const scratchDirectory = createTransformScratchDirectory(projectRoot, props.filesystem);
2834
3391
  let tracker;
@@ -2868,6 +3425,7 @@ async function transformProject(props) {
2868
3425
  filesystem: props.filesystem,
2869
3426
  projectRoot,
2870
3427
  result,
3428
+ scratchDirectory,
2871
3429
  temporaryTsconfig,
2872
3430
  });
2873
3431
  // The generation's absent resolution candidates, which get a watcher of
@@ -2883,6 +3441,7 @@ async function transformProject(props) {
2883
3441
  filesystem: props.filesystem,
2884
3442
  projectRoot,
2885
3443
  result,
3444
+ scratchDirectory,
2886
3445
  temporaryTsconfig,
2887
3446
  })
2888
3447
  : { candidates: [], watched: [] };
@@ -2908,6 +3467,7 @@ async function transformProject(props) {
2908
3467
  filesystem: props.filesystem,
2909
3468
  projectRoot,
2910
3469
  result,
3470
+ scratchDirectory,
2911
3471
  temporaryTsconfig,
2912
3472
  });
2913
3473
  const inputSnapshot = collectProjectInputSnapshot(projectRoot, identities, props.filesystem);
@@ -2920,6 +3480,7 @@ async function transformProject(props) {
2920
3480
  identities,
2921
3481
  projectRoot,
2922
3482
  result,
3483
+ scratchDirectory,
2923
3484
  });
2924
3485
  const walkStable = walkSnapshotComplete(before, declaredInputs) &&
2925
3486
  walkSnapshotComplete(inputSnapshot, declaredInputs) &&
@@ -2935,14 +3496,19 @@ async function transformProject(props) {
2935
3496
  // Overlay the in-memory source only after proving the two on-disk snapshots
2936
3497
  // stable; an unsaved editor buffer must not look like a compile-time race.
2937
3498
  const currentFileKey = toProjectKey(projectRoot, props.currentFile, identities);
2938
- inputSnapshot.hashes[currentFileKey] = hashText(props.currentSource);
2939
- // That overlay makes this one key the only recorded hash a disk signature
2940
- // cannot stand for: the bytes it names came from the bundler, not the file.
2941
- delete inputSnapshot.provenSignatures[currentFileKey];
3499
+ const currentSourceHash = hashText(props.currentSource);
3500
+ const projectInputHashes = { ...inputSnapshot.hashes };
3501
+ if (Object.prototype.hasOwnProperty.call(inputSnapshot.hashes, currentFileKey)) {
3502
+ inputSnapshot.hashes[currentFileKey] = currentSourceHash;
3503
+ // That overlay makes this one key the only recorded hash a disk signature
3504
+ // cannot stand for: the bytes it names came from the bundler, not the file.
3505
+ delete inputSnapshot.provenSignatures[currentFileKey];
3506
+ }
2942
3507
  const cached = {
2943
3508
  // Capture the out-of-walk input hashes while the generation is fresh so
2944
3509
  // cache validation can re-check them; computed before dispose so the
2945
- // exclusion of the temp-dir tsconfig is the only reason it never keys.
3510
+ // scratch-tree exclusion is the only reason its disposed artifacts never
3511
+ // key the persistent generation.
2946
3512
  externalInputHashes: {},
2947
3513
  externalInputRealpaths: {},
2948
3514
  externalInputPaths,
@@ -2952,12 +3518,14 @@ async function transformProject(props) {
2952
3518
  projectSnapshotComplete: false,
2953
3519
  projectRoot,
2954
3520
  result,
3521
+ scratchDirectory,
2955
3522
  servedFiles: new Set(),
2956
3523
  // Remember the generated temp-dir tsconfig (disposed below) so watch
2957
3524
  // derivation can drop it from the envelope's config chain; a registered
2958
3525
  // but deleted file would invalidate every persistent-cache snapshot.
2959
3526
  ...(temporaryTsconfig === undefined ? {} : { temporaryTsconfig }),
2960
3527
  };
3528
+ cached.sourceHashes = captureTransformSourceHashes(cached, props.currentFile, currentSourceHash);
2961
3529
  const externalInputSnapshot = captureExternalInputSnapshot(cached, externalInputPaths);
2962
3530
  cached.externalInputHashes = externalInputSnapshot.hashes;
2963
3531
  cached.externalInputRealpaths = externalInputSnapshot.realpaths;
@@ -2966,21 +3534,39 @@ async function transformProject(props) {
2966
3534
  // cannot be reused can say which evidence it lacked. The extra work runs
2967
3535
  // only on the failing path, where the alternative is recompiling the whole
2968
3536
  // project for every remaining module.
2969
- const graphProofs = matchesCompilerGraphInputProofs(cached);
2970
- const universalInputs = captureUniversalHostInputValidation(cached, props.currentFile) !==
2971
- undefined;
3537
+ const failures = createGenerationProofFailures();
3538
+ if (!walkStable) {
3539
+ recordProjectSnapshotFailures(failures, {
3540
+ before,
3541
+ candidateTracker,
3542
+ declared: declaredInputs,
3543
+ hostInputTracker,
3544
+ identities,
3545
+ projectRoot,
3546
+ snapshot: inputSnapshot,
3547
+ tracker,
3548
+ });
3549
+ }
3550
+ const graphFailures = compilerGraphInputProofFailures(cached);
3551
+ mergeGenerationProofFailures(failures, graphFailures);
3552
+ mergeGenerationProofFailures(failures, externalInputSnapshot.failures);
3553
+ const universalInputCapture = captureUniversalHostInputValidation(cached, props.currentFile);
3554
+ mergeGenerationProofFailures(failures, universalInputCapture.failures);
3555
+ const graphProofs = graphFailures.entries.length === 0 && graphFailures.omitted === 0;
3556
+ const universalInputs = universalInputCapture.validation !== undefined;
2972
3557
  const stableProjectSnapshot = walkStable &&
2973
3558
  graphProofs &&
2974
3559
  externalInputSnapshot.complete &&
2975
3560
  universalInputs;
2976
- // Only a caching host loses anything here: without a cache every delivery
2977
- // compiles by design, so an unprovable generation costs it nothing.
2978
- if (!stableProjectSnapshot && props.trackProjectMembership) {
2979
- reportUnreusableGeneration(cached, {
2980
- externalInputs: externalInputSnapshot.complete,
2981
- graphProofs,
2982
- universalInputs,
2983
- walkStable,
3561
+ if (!stableProjectSnapshot) {
3562
+ TRANSFORM_GENERATION_FAILURES.set(result, failures);
3563
+ TRANSFORM_FAILED_GENERATION_VALIDATIONS.set(result, {
3564
+ cached,
3565
+ declaredInputs,
3566
+ inputStates: captureFailedGenerationInputStates(cached, failures),
3567
+ projectInputHashes,
3568
+ projectWalkComplete: walkSnapshotComplete(inputSnapshot, declaredInputs),
3569
+ projectWalkFailures: projectWalkFailureFingerprint(inputSnapshot, declaredInputs, projectRoot, identities),
2984
3570
  });
2985
3571
  }
2986
3572
  cached.projectSnapshotComplete = stableProjectSnapshot;
@@ -3031,16 +3617,23 @@ async function transformProject(props) {
3031
3617
  }
3032
3618
  }
3033
3619
  }
3034
- /** Exclude the disposed overlay tsconfig from live host-input tracking. */
3620
+ /** Exclude disposed transform scratch from live host-input tracking. */
3035
3621
  function selectPersistentHostInputs(props) {
3036
3622
  if (props.result.type === "exception")
3037
3623
  return [];
3038
3624
  const inputs = selectListedFiles(props.projectRoot, props.result.hostInputs);
3039
- if (props.temporaryTsconfig === undefined)
3625
+ if (props.scratchDirectory === undefined &&
3626
+ props.temporaryTsconfig === undefined)
3040
3627
  return inputs;
3041
3628
  const identities = createHostPathIdentityContext(props.filesystem);
3042
- const temporary = pathIdentityKey(props.temporaryTsconfig, identities);
3043
- return inputs.filter((input) => pathIdentityKey(input, identities) !== temporary);
3629
+ const temporary = props.temporaryTsconfig === undefined
3630
+ ? undefined
3631
+ : pathIdentityKey(props.temporaryTsconfig, identities);
3632
+ return inputs.filter((input) => {
3633
+ if (isTransformScratchInput(input, props.scratchDirectory))
3634
+ return false;
3635
+ return pathIdentityKey(input, identities) !== temporary;
3636
+ });
3044
3637
  }
3045
3638
  function createTransformTsconfig(props, scratchDirectory) {
3046
3639
  const compilerOptions = normalizeCompilerOptionsForGeneratedTsconfig({
@@ -3131,6 +3724,11 @@ function pathIsWithin(child, parent) {
3131
3724
  !relative.startsWith(`..${path.sep}`) &&
3132
3725
  !path.isAbsolute(relative)));
3133
3726
  }
3727
+ /** Whether an input is owned by the disposable transform scratch tree. */
3728
+ function isTransformScratchInput(input, scratchDirectory) {
3729
+ return (scratchDirectory !== undefined &&
3730
+ pathIsWithin(path.resolve(input), path.resolve(scratchDirectory)));
3731
+ }
3134
3732
  /** Route all compiler/plugin scratch to one owned directory outside project. */
3135
3733
  function transformScratchEnvironment(directory) {
3136
3734
  return {