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