@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
@@ -53,8 +53,24 @@ interface TtscProjectDirectorySnapshot {
53
53
  signature: string;
54
54
  }
55
55
 
56
+ /** One project-walk observation that could not prove a coherent snapshot. */
57
+ interface TtscProjectWalkFailure {
58
+ kind:
59
+ | "directory-changed-during-walk"
60
+ | "directory-metadata-unavailable"
61
+ | "directory-read-failed"
62
+ | "file-changed-during-read"
63
+ | "file-read-failed";
64
+ /** Absolute lexical spelling observed by the walk. */
65
+ path: string;
66
+ }
67
+
56
68
  /** Generation-scoped directory watchers used to detect membership changes. */
57
69
  interface TtscProjectMutationTracker {
70
+ /** Absolute paths named by generation-time mutation events. */
71
+ changes: Set<string>;
72
+ /** Whether additional event paths were discarded after the witness bound. */
73
+ changesOmitted: boolean;
58
74
  close: () => void;
59
75
  /**
60
76
  * Absolute spellings whose creation, change or removal this tracker would
@@ -82,6 +98,80 @@ interface TtscProjectMutationTracker {
82
98
  settle?: Promise<void>;
83
99
  }
84
100
 
101
+ /** One reason a whole-project transform cannot become a reusable generation. */
102
+ interface TtscGenerationProofFailure {
103
+ domain: "external" | "graph" | "host" | "project";
104
+ /** Machine-readable failure class printed verbatim in terminal diagnostics. */
105
+ kind: string;
106
+ /** Optional producer detail, such as the native compiler observation failure. */
107
+ detail?: string;
108
+ /** Absolute lexical spelling of the input or directory that failed proof. */
109
+ path?: string;
110
+ }
111
+
112
+ /** Bounded proof witnesses for one transform attempt. */
113
+ interface TtscGenerationProofFailures {
114
+ entries: TtscGenerationProofFailure[];
115
+ omitted: number;
116
+ seen: Set<string>;
117
+ }
118
+
119
+ /** Filesystem state that may authorize replacing one terminal failed generation. */
120
+ interface TtscFailedGenerationValidation {
121
+ /** Last attempted generation, retained only as a comparison baseline. */
122
+ cached: TtscCachedProjectTransform;
123
+ /** Input keys whose content can affect the generation, or the whole walk. */
124
+ declaredInputs: ReadonlySet<string> | undefined;
125
+ /** Fingerprints of every out-of-walk and exact host input. */
126
+ inputStates: ReadonlyMap<string, string>;
127
+ /** Unmodified on-disk project hashes before the in-memory source overlay. */
128
+ projectInputHashes: Readonly<Record<string, string>>;
129
+ /** Coherence and exact failure state of the final project walk. */
130
+ projectWalkComplete: boolean;
131
+ projectWalkFailures: string;
132
+ }
133
+
134
+ /** A bounded proof failure that stays authoritative until its inputs change. */
135
+ class TtscUnstableGenerationError extends Error {
136
+ public readonly validation: TtscFailedGenerationValidation;
137
+
138
+ public constructor(
139
+ message: string,
140
+ validation: TtscFailedGenerationValidation,
141
+ ) {
142
+ super(message);
143
+ this.name = "TtscUnstableGenerationError";
144
+ this.validation = validation;
145
+ }
146
+ }
147
+
148
+ /** Proof witnesses retained beside a compiler result without extending its API. */
149
+ const TRANSFORM_GENERATION_FAILURES = new WeakMap<
150
+ ITtscCompilerTransformation,
151
+ TtscGenerationProofFailures
152
+ >();
153
+
154
+ /** Retry baselines retained only for attempts that could not be published. */
155
+ const TRANSFORM_FAILED_GENERATION_VALIDATIONS = new WeakMap<
156
+ ITtscCompilerTransformation,
157
+ TtscFailedGenerationValidation
158
+ >();
159
+
160
+ /** Rejected cache promises whose unchanged terminal verdict may be replayed. */
161
+ const TERMINAL_TRANSFORM_GENERATIONS = new WeakMap<
162
+ Promise<TtscCachedProjectTransform>,
163
+ TtscUnstableGenerationError
164
+ >();
165
+
166
+ /** Maximum witnesses printed and retained for each failed transform attempt. */
167
+ const MAX_GENERATION_PROOF_FAILURES = 8;
168
+
169
+ /** Maximum exact mutation paths kept after a tracker already proved a change. */
170
+ const MAX_GENERATION_MUTATION_PATHS = 8;
171
+
172
+ /** One retry absorbs a transient watch write without admitting an infinite loop. */
173
+ const TRANSFORM_GENERATION_ATTEMPTS = 2;
174
+
85
175
  /**
86
176
  * A single entry in the project transform cache.
87
177
  *
@@ -149,6 +239,13 @@ export interface TtscCachedProjectTransform {
149
239
  * disk bytes against the recorded hash, and may record a signature then.
150
240
  */
151
241
  inputSignatures?: Record<string, string>;
242
+ /**
243
+ * Raw source hash of every readable key in the transform output, keyed by
244
+ * filesystem identity. Unlike {@link inputHashes}, this includes source
245
+ * outputs outside the project walk without adding arbitrary output keys to
246
+ * the complete project snapshot.
247
+ */
248
+ sourceHashes?: Record<string, string>;
152
249
  /** Metadata snapshot of every directory in the stable generation walk. */
153
250
  projectDirectories?: TtscProjectDirectorySnapshot[];
154
251
  /** Live notification state for universal host-input changes. */
@@ -199,13 +296,19 @@ export interface TtscCachedProjectTransform {
199
296
  * module's first delivery inside the current build.
200
297
  */
201
298
  servedFiles?: Set<string>;
299
+ /**
300
+ * Absolute path of the adapter-owned scratch directory used for this
301
+ * generation. It is disposed after compilation, so none of its compiler,
302
+ * resolver, or plugin artifacts can be a persistent cache or watch input.
303
+ */
304
+ scratchDirectory?: string;
202
305
  /**
203
306
  * Absolute path of the generated temp-dir tsconfig this compile ran against,
204
307
  * when an alias/compiler-options overlay required one. The compiler reports
205
308
  * it in the envelope's `graph.configs` chain, but it is disposed right after
206
309
  * the compile, so registering it as a watch input would invalidate every
207
- * bundler cache snapshot on the next build; watch derivation must skip
208
- * exactly this path.
310
+ * bundler cache snapshot on the next build; watch derivation must skip this
311
+ * path. {@link scratchDirectory} owns the wider disposable-input bound.
209
312
  */
210
313
  temporaryTsconfig?: string;
211
314
  }
@@ -488,8 +591,29 @@ export async function transformTtsc(
488
591
  for (;;) {
489
592
  let transformed = cache?.get(key);
490
593
  if (transformed !== undefined) {
491
- // A rejected in-flight generation must not stay cached: evict it (only if
492
- // it is still the current entry) so a later call re-runs the transform.
594
+ const terminal = TERMINAL_TRANSFORM_GENERATIONS.get(transformed);
595
+ if (terminal !== undefined) {
596
+ // A proof failure is a verdict about one observed environment, not an
597
+ // invitation for every later module to repeat the whole compile. Keep
598
+ // replaying it until a source/input probe or an explicit cache reset
599
+ // establishes that a new generation could differ.
600
+ if (
601
+ !failedGenerationEnvironmentChanged(terminal.validation, {
602
+ currentFile: file,
603
+ currentSource: source,
604
+ filesystem,
605
+ })
606
+ ) {
607
+ throw terminal;
608
+ }
609
+ evictGeneration(cache, key, transformed);
610
+ if (cache?.get(key) !== undefined) {
611
+ continue;
612
+ }
613
+ transformed = undefined;
614
+ }
615
+ }
616
+ if (transformed !== undefined) {
493
617
  const cached = await awaitOrEvict(cache, key, transformed);
494
618
  TRANSFORM_RESULT_FILESYSTEM.set(cached.result, filesystem);
495
619
  // While this caller awaited the old Promise, another caller may have
@@ -575,13 +699,15 @@ export async function transformTtsc(
575
699
  }
576
700
 
577
701
  /**
578
- * Await a cached generation, evicting it on rejection.
702
+ * Await a cached generation, retaining only terminal proof failures.
579
703
  *
580
704
  * The cache stores the in-flight transform Promise before it settles so
581
- * concurrent callers share one compilation. A rejected generation must not
582
- * remain the authoritative cached result, or a transient toolchain/host failure
583
- * becomes permanent for a long-lived worker. Eviction is identity-guarded so a
584
- * newer generation another caller installed under the same key survives.
705
+ * concurrent callers share one compilation. Ordinary compiler and host
706
+ * rejections are evicted so a transient failure cannot become permanent. A
707
+ * bounded stabilization failure is different: it already spent its retry and
708
+ * repeating it for every later module recreates the issue this gate prevents.
709
+ * It stays authoritative until its retained input baseline changes or the cache
710
+ * owner starts a new lifecycle.
585
711
  */
586
712
  async function awaitOrEvict(
587
713
  cache: TtscTransformCache | undefined,
@@ -591,7 +717,14 @@ async function awaitOrEvict(
591
717
  try {
592
718
  return await generation;
593
719
  } catch (error) {
594
- evictGeneration(cache, key, generation);
720
+ if (
721
+ error instanceof TtscUnstableGenerationError &&
722
+ cache?.get(key) === generation
723
+ ) {
724
+ TERMINAL_TRANSFORM_GENERATIONS.set(generation, error);
725
+ } else {
726
+ evictGeneration(cache, key, generation);
727
+ }
595
728
  throw error;
596
729
  }
597
730
  }
@@ -791,6 +924,8 @@ interface TtscEnvelopeGraphIndexes {
791
924
  string,
792
925
  { hash: string | null; path: string; realpath: string | null }
793
926
  >;
927
+ /** Native compiler-observation failure for an unproven member identity. */
928
+ readonly inputProofFailures: Map<string, string>;
794
929
  /** Aliased graph proof keys that reported contradictory generation states. */
795
930
  readonly inputProofConflicts: Set<string>;
796
931
  }
@@ -849,6 +984,7 @@ function envelopeGraphIndexes(
849
984
  members: new Set(),
850
985
  speculative: new Set(),
851
986
  inputProofs: new Map(),
987
+ inputProofFailures: new Map(),
852
988
  inputProofConflicts: new Set(),
853
989
  };
854
990
  const graph =
@@ -871,7 +1007,11 @@ function envelopeGraphIndexes(
871
1007
  )
872
1008
  .map((target) => {
873
1009
  const absoluteTarget = path.resolve(props.projectRoot, target);
874
- built.members.add(derivationIdentity(state, absoluteTarget));
1010
+ const targetIdentity = derivationIdentity(state, absoluteTarget);
1011
+ built.members.add(targetIdentity);
1012
+ if (!built.spellings.has(targetIdentity)) {
1013
+ built.spellings.set(targetIdentity, absoluteTarget);
1014
+ }
875
1015
  return absoluteTarget;
876
1016
  }),
877
1017
  );
@@ -880,7 +1020,9 @@ function envelopeGraphIndexes(
880
1020
  built.globals.push(...selectListedFiles(props.projectRoot, graph.globals));
881
1021
  built.configs.push(...selectListedFiles(props.projectRoot, graph.configs));
882
1022
  for (const input of [...built.globals, ...built.configs]) {
883
- built.members.add(derivationIdentity(state, input));
1023
+ const identity = derivationIdentity(state, input);
1024
+ built.members.add(identity);
1025
+ if (!built.spellings.has(identity)) built.spellings.set(identity, input);
884
1026
  }
885
1027
  const candidateEntries = Object.entries(graph.candidates ?? {}).filter(
886
1028
  (entry) => Array.isArray(entry[1]),
@@ -890,9 +1032,12 @@ function envelopeGraphIndexes(
890
1032
  // candidate could be classified speculative before a later entry proves
891
1033
  // the same path is a realized source.
892
1034
  for (const [source] of candidateEntries) {
893
- built.members.add(
894
- derivationIdentity(state, path.resolve(props.projectRoot, source)),
895
- );
1035
+ const absoluteSource = path.resolve(props.projectRoot, source);
1036
+ const identity = derivationIdentity(state, absoluteSource);
1037
+ built.members.add(identity);
1038
+ if (!built.spellings.has(identity)) {
1039
+ built.spellings.set(identity, absoluteSource);
1040
+ }
896
1041
  }
897
1042
  const realized = new Set(built.members);
898
1043
  for (const [source, candidates] of candidateEntries) {
@@ -914,6 +1059,22 @@ function envelopeGraphIndexes(
914
1059
  // only as a candidate.
915
1060
  if (!realized.has(identity)) built.speculative.add(identity);
916
1061
  built.members.add(identity);
1062
+ if (!built.spellings.has(identity)) {
1063
+ built.spellings.set(
1064
+ identity,
1065
+ path.resolve(props.projectRoot, candidate),
1066
+ );
1067
+ }
1068
+ }
1069
+ }
1070
+ const transformSources = new Set<string>();
1071
+ if (props.result.type === "success") {
1072
+ for (const output of Object.keys(props.result.typescript)) {
1073
+ if (!isDeclarationFile(output)) {
1074
+ transformSources.add(
1075
+ derivationIdentity(state, path.resolve(props.projectRoot, output)),
1076
+ );
1077
+ }
917
1078
  }
918
1079
  }
919
1080
  for (const [input, hash] of Object.entries(graph.inputHashes ?? {})) {
@@ -939,7 +1100,9 @@ function envelopeGraphIndexes(
939
1100
  }
940
1101
  const absolute = path.resolve(props.projectRoot, input);
941
1102
  const identity = derivationIdentity(state, absolute);
942
- if (!built.members.has(identity)) continue;
1103
+ if (!built.members.has(identity) && !transformSources.has(identity)) {
1104
+ continue;
1105
+ }
943
1106
  const proof = {
944
1107
  hash,
945
1108
  path: absolute,
@@ -962,6 +1125,25 @@ function envelopeGraphIndexes(
962
1125
  built.inputProofs.set(identity, proof);
963
1126
  }
964
1127
  }
1128
+ for (const [input, reason] of Object.entries(
1129
+ graph.inputProofFailures ?? {},
1130
+ )) {
1131
+ if (typeof reason !== "string" || !/^[a-z0-9-]{1,64}$/.test(reason)) {
1132
+ continue;
1133
+ }
1134
+ const absolute = path.resolve(props.projectRoot, input);
1135
+ const identity = derivationIdentity(state, absolute);
1136
+ if (!built.members.has(identity) && !transformSources.has(identity)) {
1137
+ continue;
1138
+ }
1139
+ if (built.inputProofs.has(identity)) {
1140
+ built.inputProofs.delete(identity);
1141
+ built.inputProofConflicts.add(identity);
1142
+ }
1143
+ if (!built.inputProofFailures.has(identity)) {
1144
+ built.inputProofFailures.set(identity, reason);
1145
+ }
1146
+ }
965
1147
  }
966
1148
  state.graph = built;
967
1149
  return built;
@@ -1018,8 +1200,9 @@ function collectDeclaredIdentities(
1018
1200
  * Envelope keys mirror the `typescript` keys (project-relative); values may be
1019
1201
  * project-relative or absolute. Every path is absolutized against the project
1020
1202
  * root and deduplicated; the file itself is dropped (the bundler already
1021
- * watches the module it transforms), and so is the disposed temp-dir tsconfig
1022
- * (see {@link TtscCachedProjectTransform.temporaryTsconfig}).
1203
+ * watches the module it transforms), and so is every path in the disposed
1204
+ * transform scratch tree (see
1205
+ * {@link TtscCachedProjectTransform.scratchDirectory}).
1023
1206
  */
1024
1207
  function notifyWatchInputs(
1025
1208
  hooks: TtscTransformHooks | undefined,
@@ -1036,6 +1219,7 @@ function notifyWatchInputs(
1036
1219
  file,
1037
1220
  projectRoot: cached.projectRoot,
1038
1221
  result: cached.result,
1222
+ scratchDirectory: cached.scratchDirectory,
1039
1223
  temporaryTsconfig: cached.temporaryTsconfig,
1040
1224
  })) {
1041
1225
  // Hand the adapter the identity this generation already resolved and the
@@ -1078,6 +1262,7 @@ function selectWatchInputs(props: {
1078
1262
  file: string;
1079
1263
  projectRoot: string;
1080
1264
  result: ITtscCompilerTransformation;
1265
+ scratchDirectory?: string;
1081
1266
  temporaryTsconfig?: string;
1082
1267
  }): string[] {
1083
1268
  if (props.result.type === "exception") {
@@ -1101,6 +1286,7 @@ function deriveWatchInputs(
1101
1286
  file: string;
1102
1287
  projectRoot: string;
1103
1288
  result: ITtscCompilerTransformation;
1289
+ scratchDirectory?: string;
1104
1290
  temporaryTsconfig?: string;
1105
1291
  },
1106
1292
  fileIdentity: string,
@@ -1123,6 +1309,7 @@ function deriveWatchInputs(
1123
1309
  if (
1124
1310
  spelling === currentSpelling ||
1125
1311
  spelling === temporarySpelling ||
1312
+ isTransformScratchInput(spelling, props.scratchDirectory) ||
1126
1313
  lexicalSeen.has(spelling)
1127
1314
  ) {
1128
1315
  return;
@@ -1132,6 +1319,7 @@ function deriveWatchInputs(
1132
1319
  output.push(input);
1133
1320
  };
1134
1321
  const appendPhysical = (input: string): void => {
1322
+ if (isTransformScratchInput(input, props.scratchDirectory)) return;
1135
1323
  const identity = derivationIdentity(state, input);
1136
1324
  if (excluded.has(identity) || physicalSeen.has(identity)) return;
1137
1325
  physicalSeen.add(identity);
@@ -1462,11 +1650,24 @@ export function stripQuery(id: string): string {
1462
1650
  }
1463
1651
 
1464
1652
  /**
1465
- * Returns `true` for TypeScript declaration files (`.d.ts`, `.d.mts`,
1466
- * `.d.cts`).
1653
+ * Returns `true` for every declaration-file spelling TypeScript-Go accepts.
1654
+ * Besides the standard `.d.ts`, `.d.mts`, and `.d.cts` forms, TypeScript-Go
1655
+ * treats an arbitrary-extension source such as `styles.d.css.ts` as a
1656
+ * declaration file too.
1467
1657
  */
1468
1658
  export function isDeclarationFile(id: string): boolean {
1469
- return id.endsWith(".d.ts") || id.endsWith(".d.mts") || id.endsWith(".d.cts");
1659
+ // Module ids can cross process/platform boundaries (for example, a Windows
1660
+ // id inspected by a POSIX host). TypeScript-Go normalizes both separators
1661
+ // before taking the basename, so a `.d.` directory component must not turn
1662
+ // an ordinary source into a declaration file.
1663
+ const normalized = id.replaceAll("\\", "/");
1664
+ const base = normalized.slice(normalized.lastIndexOf("/") + 1);
1665
+ return (
1666
+ base.endsWith(".d.ts") ||
1667
+ base.endsWith(".d.mts") ||
1668
+ base.endsWith(".d.cts") ||
1669
+ (base.endsWith(".ts") && base.includes(".d."))
1670
+ );
1470
1671
  }
1471
1672
 
1472
1673
  /**
@@ -1519,7 +1720,12 @@ function matchesCachedSource(
1519
1720
  ): boolean {
1520
1721
  const identities = envelopeDerivation(cached).identityContext;
1521
1722
  const currentKey = toProjectKey(cached.projectRoot, file, identities);
1522
- if (cached.inputHashes[currentKey] !== hashText(source)) {
1723
+ const identity = pathIdentityKey(file, identities);
1724
+ const expected =
1725
+ cached.sourceHashes?.[identity] ??
1726
+ cached.inputHashes[currentKey] ??
1727
+ cached.externalInputHashes?.[identity];
1728
+ if (expected !== hashText(source)) {
1523
1729
  return false;
1524
1730
  }
1525
1731
  if (
@@ -1583,6 +1789,7 @@ function matchesNarrowPersistentInputs(
1583
1789
  file,
1584
1790
  projectRoot: cached.projectRoot,
1585
1791
  result: cached.result,
1792
+ scratchDirectory: cached.scratchDirectory,
1586
1793
  temporaryTsconfig: cached.temporaryTsconfig,
1587
1794
  });
1588
1795
  for (const input of inputs) {
@@ -1839,9 +2046,13 @@ function isMissingPathError(error: unknown): boolean {
1839
2046
  function captureUniversalHostInputValidation(
1840
2047
  cached: TtscCachedProjectTransform,
1841
2048
  currentFile: string,
1842
- ): TtscHostInputValidation | undefined {
2049
+ ): {
2050
+ failures: TtscGenerationProofFailures;
2051
+ validation?: TtscHostInputValidation;
2052
+ } {
1843
2053
  const filesystem = resultFilesystem(cached.result);
1844
2054
  const state = envelopeDerivation(cached);
2055
+ const failures = createGenerationProofFailures();
1845
2056
  const validation: TtscHostInputValidation = {
1846
2057
  entries: new Map(),
1847
2058
  covered: new Set(),
@@ -1851,6 +2062,7 @@ function captureUniversalHostInputValidation(
1851
2062
  filesystem,
1852
2063
  projectRoot: cached.projectRoot,
1853
2064
  result: cached.result,
2065
+ scratchDirectory: cached.scratchDirectory,
1854
2066
  temporaryTsconfig: cached.temporaryTsconfig,
1855
2067
  })) {
1856
2068
  const generationHashes =
@@ -1868,7 +2080,14 @@ function captureUniversalHostInputValidation(
1868
2080
  let readable = false;
1869
2081
  if (expected === undefined) {
1870
2082
  const current = path.resolve(currentFile);
1871
- if (path.resolve(input) !== current) return undefined;
2083
+ if (path.resolve(input) !== current) {
2084
+ recordGenerationProofFailure(failures, {
2085
+ domain: "host",
2086
+ kind: "content-proof-missing",
2087
+ path: input,
2088
+ });
2089
+ return { failures };
2090
+ }
1872
2091
  // The current module may be supplied from an unsaved editor buffer. Its
1873
2092
  // generation snapshot is overlaid below from `currentSource`, so a disk
1874
2093
  // fingerprint would be both unavailable and the wrong authority. The
@@ -1877,7 +2096,12 @@ function captureUniversalHostInputValidation(
1877
2096
  } else {
1878
2097
  const current = hostInputStateHash(input, filesystem);
1879
2098
  if (expected !== current) {
1880
- return undefined;
2099
+ recordGenerationProofFailure(failures, {
2100
+ domain: "host",
2101
+ kind: "content-changed",
2102
+ path: input,
2103
+ });
2104
+ return { failures };
1881
2105
  }
1882
2106
  // A path both sides agree they could not read carries no bytes for a
1883
2107
  // signature to stand for. It still belongs in the manifest, so the
@@ -1897,14 +2121,38 @@ function captureUniversalHostInputValidation(
1897
2121
  state.identityContext,
1898
2122
  )
1899
2123
  ) {
1900
- return undefined;
2124
+ recordGenerationProofFailure(failures, {
2125
+ domain: "host",
2126
+ kind: Object.prototype.hasOwnProperty.call(
2127
+ generationRealpaths,
2128
+ absoluteInput,
2129
+ )
2130
+ ? "realpath-changed"
2131
+ : "realpath-proof-missing",
2132
+ path: input,
2133
+ });
2134
+ return { failures };
1901
2135
  }
1902
2136
  }
1903
2137
  validation.covered.add(path.resolve(input));
1904
2138
  const before = inputMetadataEvidence(input, filesystem);
1905
- if (!matchesRecordedInput(cached, input)) return undefined;
2139
+ if (!matchesRecordedInput(cached, input)) {
2140
+ recordGenerationProofFailure(failures, {
2141
+ domain: "host",
2142
+ kind: "snapshot-mismatch",
2143
+ path: input,
2144
+ });
2145
+ return { failures };
2146
+ }
1906
2147
  const after = inputMetadataSignature(input, filesystem);
1907
- if (before?.signature !== after) return undefined;
2148
+ if (before?.signature !== after) {
2149
+ recordGenerationProofFailure(failures, {
2150
+ domain: "host",
2151
+ kind: "changed-during-validation",
2152
+ path: input,
2153
+ });
2154
+ return { failures };
2155
+ }
1908
2156
  if (before !== undefined) {
1909
2157
  // Do not key this manifest by physical identity. A symlink/junction
1910
2158
  // spelling and its selected target deliberately share that identity,
@@ -1924,7 +2172,14 @@ function captureUniversalHostInputValidation(
1924
2172
  const probe = missingPathProbe(input, filesystem);
1925
2173
  if (probe.blocker !== undefined) {
1926
2174
  const signature = inputMetadataSignature(probe.blocker, filesystem);
1927
- if (signature === undefined) return undefined;
2175
+ if (signature === undefined) {
2176
+ recordGenerationProofFailure(failures, {
2177
+ domain: "host",
2178
+ kind: "blocker-metadata-unavailable",
2179
+ path: probe.blocker,
2180
+ });
2181
+ return { failures };
2182
+ }
1928
2183
  // A blocker proves a kind and an identity, not content: it is the
1929
2184
  // non-directory ancestor that makes everything below it unreachable, and
1930
2185
  // it cannot stop being that without its metadata moving. So it keeps a
@@ -1956,7 +2211,7 @@ function captureUniversalHostInputValidation(
1956
2211
  );
1957
2212
  }
1958
2213
  cached.hostInputValidation = validation;
1959
- return validation;
2214
+ return { failures, validation };
1960
2215
  }
1961
2216
 
1962
2217
  /**
@@ -2347,7 +2602,9 @@ function matchesCompleteInputSnapshot(
2347
2602
  ) {
2348
2603
  return false;
2349
2604
  }
2350
- current.hashes[currentKey] = hashText(source);
2605
+ if (Object.prototype.hasOwnProperty.call(cached.inputHashes, currentKey)) {
2606
+ current.hashes[currentKey] = hashText(source);
2607
+ }
2351
2608
  if (!sameHashes(cached.inputHashes, current.hashes, declaredInputs)) {
2352
2609
  return false;
2353
2610
  }
@@ -2430,16 +2687,17 @@ function matchesExternalInputRealpaths(
2430
2687
 
2431
2688
  /**
2432
2689
  * Capture external-input hashes without attaching post-compile state to an
2433
- * earlier graph. Graph members must carry compiler-time proof and still match
2434
- * it now; plugin-declared dependency-only paths retain the historical
2435
- * post-compile snapshot because their own protocol does not claim generation
2436
- * fingerprints.
2690
+ * earlier graph. Graph members and out-of-walk transformed sources must carry
2691
+ * compiler-time proof and still match it now; plugin-declared dependency-only
2692
+ * paths retain the historical post-compile snapshot because their own protocol
2693
+ * does not claim generation fingerprints.
2437
2694
  */
2438
2695
  function captureExternalInputSnapshot(
2439
2696
  cached: TtscCachedProjectTransform,
2440
2697
  paths: readonly string[],
2441
2698
  ): {
2442
2699
  complete: boolean;
2700
+ failures: TtscGenerationProofFailures;
2443
2701
  hashes: Record<string, string>;
2444
2702
  realpaths: Record<string, string | null>;
2445
2703
  signatures: Record<string, string>;
@@ -2447,9 +2705,23 @@ function captureExternalInputSnapshot(
2447
2705
  const state = envelopeDerivation(cached);
2448
2706
  const filesystem = resultFilesystem(cached.result);
2449
2707
  const graph = envelopeGraphIndexes(state, cached);
2708
+ // A non-declaration transform output is a compiler-realized source even when
2709
+ // a malformed or legacy graph omitted its node. Its output was computed from
2710
+ // compiler-time bytes, so a post-compile host read cannot prove coherence.
2711
+ const transformSources = new Set<string>();
2712
+ if (cached.result.type === "success") {
2713
+ for (const output of Object.keys(cached.result.typescript)) {
2714
+ if (!isDeclarationFile(output)) {
2715
+ transformSources.add(
2716
+ derivationIdentity(state, path.resolve(cached.projectRoot, output)),
2717
+ );
2718
+ }
2719
+ }
2720
+ }
2450
2721
  const hashes: Record<string, string> = {};
2451
2722
  const realpaths: Record<string, string | null> = {};
2452
2723
  const signatures: Record<string, string> = {};
2724
+ const failures = createGenerationProofFailures();
2453
2725
  let complete = true;
2454
2726
  // Sandwich every read between two metadata signatures. Only a signature that
2455
2727
  // survived its own read, and whose stamp's tick the filesystem's clock has
@@ -2471,28 +2743,54 @@ function captureExternalInputSnapshot(
2471
2743
  // through to the recorded-state branch below, the same evidence a
2472
2744
  // plugin-declared dependency path carries. Its absence still invalidates
2473
2745
  // the generation when it appears, because `missing` is recorded state.
2746
+ const realizedTransformSource = transformSources.has(identity);
2474
2747
  const speculativeOnly =
2748
+ !realizedTransformSource &&
2475
2749
  graph.speculative.has(identity) &&
2476
2750
  !graph.inputProofs.has(identity) &&
2477
2751
  !graph.inputProofConflicts.has(identity);
2478
- if (graph.members.has(identity) && !speculativeOnly) {
2752
+ if (
2753
+ (realizedTransformSource || graph.members.has(identity)) &&
2754
+ !speculativeOnly
2755
+ ) {
2479
2756
  const proof = graph.inputProofs.get(identity);
2480
2757
  if (proof === undefined || graph.inputProofConflicts.has(identity)) {
2481
2758
  complete = false;
2759
+ recordGenerationProofFailure(failures, {
2760
+ domain: "external",
2761
+ kind: graph.inputProofConflicts.has(identity)
2762
+ ? "graph-proof-conflict"
2763
+ : "graph-proof-missing",
2764
+ detail: graph.inputProofFailures.get(identity),
2765
+ path: input,
2766
+ });
2482
2767
  continue;
2483
2768
  }
2484
2769
  const before = inputMetadataEvidence(input, filesystem);
2485
2770
  const currentHash = graphInputStateHash(input, filesystem);
2771
+ const currentRealpath = hostInputRealpath(input, filesystem);
2486
2772
  const after = inputMetadataSignature(input, filesystem);
2487
- if (
2488
- currentHash !== proof.hash ||
2489
- !sameHostInputRealpath(
2490
- proof.realpath,
2491
- hostInputRealpath(input, filesystem),
2492
- state.identityContext,
2493
- )
2494
- ) {
2773
+ const realpathMatches = sameHostInputRealpath(
2774
+ proof.realpath,
2775
+ currentRealpath,
2776
+ state.identityContext,
2777
+ );
2778
+ if (currentHash !== proof.hash || !realpathMatches) {
2495
2779
  complete = false;
2780
+ if (currentHash !== proof.hash) {
2781
+ recordGenerationProofFailure(failures, {
2782
+ domain: "external",
2783
+ kind: "graph-content-changed",
2784
+ path: input,
2785
+ });
2786
+ }
2787
+ if (!realpathMatches) {
2788
+ recordGenerationProofFailure(failures, {
2789
+ domain: "external",
2790
+ kind: "graph-realpath-changed",
2791
+ path: input,
2792
+ });
2793
+ }
2496
2794
  } else if (currentHash !== null) {
2497
2795
  // The recorded hash is the compiler's own proof, so a signature may
2498
2796
  // only stand for it once the current bytes were shown to match it.
@@ -2510,32 +2808,36 @@ function captureExternalInputSnapshot(
2510
2808
  hashes[identity] = hash ?? MISSING_INPUT_STATE;
2511
2809
  if (hash !== null) record(input, before, after);
2512
2810
  }
2513
- return { complete, hashes, realpaths, signatures };
2811
+ return { complete, failures, hashes, realpaths, signatures };
2514
2812
  }
2515
2813
 
2516
- /** Verify every graph member still has the state read by the compiler. */
2517
- function matchesCompilerGraphInputProofs(
2814
+ /** Explain every graph member that no longer matches the compiler's state. */
2815
+ function compilerGraphInputProofFailures(
2518
2816
  cached: TtscCachedProjectTransform,
2519
- ): boolean {
2817
+ ): TtscGenerationProofFailures {
2818
+ const failures = createGenerationProofFailures();
2520
2819
  if (
2521
2820
  cached.result.type === "exception" ||
2522
2821
  cached.result.graph === undefined ||
2523
2822
  (cached.result.graph.inputHashes === undefined &&
2524
- cached.result.graph.inputRealpaths === undefined)
2823
+ cached.result.graph.inputRealpaths === undefined &&
2824
+ cached.result.graph.inputProofFailures === undefined)
2525
2825
  ) {
2526
2826
  // Legacy sidecars remain compatible for ordinary in-project graphs. Their
2527
2827
  // out-of-walk members are still rejected by captureExternalInputSnapshot,
2528
2828
  // where a post-compile snapshot cannot prove the compiler's generation.
2529
- return true;
2829
+ return failures;
2530
2830
  }
2531
2831
  const state = envelopeDerivation(cached);
2532
2832
  const filesystem = resultFilesystem(cached.result);
2533
2833
  const graph = envelopeGraphIndexes(state, cached);
2534
- if (graph.inputProofConflicts.size !== 0) {
2535
- return false;
2536
- }
2537
2834
  for (const identity of graph.members) {
2538
2835
  const proof = graph.inputProofs.get(identity);
2836
+ const spelling =
2837
+ proof?.path ?? graph.spellings.get(identity) ?? cached.projectRoot;
2838
+ if (isTransformScratchInput(spelling, cached.scratchDirectory)) {
2839
+ continue;
2840
+ }
2539
2841
  // A speculative candidate has no compile-time read to prove. Requiring one
2540
2842
  // would void every generation of every project whose resolution passes over
2541
2843
  // a higher-priority spelling, which is every project with a dependency
@@ -2544,19 +2846,47 @@ function matchesCompilerGraphInputProofs(
2544
2846
  if (proof === undefined && graph.speculative.has(identity)) {
2545
2847
  continue;
2546
2848
  }
2849
+ if (graph.inputProofConflicts.has(identity)) {
2850
+ recordGenerationProofFailure(failures, {
2851
+ domain: "graph",
2852
+ kind: "proof-conflict",
2853
+ detail: graph.inputProofFailures.get(identity),
2854
+ path: spelling,
2855
+ });
2856
+ continue;
2857
+ }
2858
+ if (proof === undefined) {
2859
+ recordGenerationProofFailure(failures, {
2860
+ domain: "graph",
2861
+ kind: "proof-missing",
2862
+ detail: graph.inputProofFailures.get(identity),
2863
+ path: spelling,
2864
+ });
2865
+ continue;
2866
+ }
2867
+ const currentHash = graphInputStateHash(proof.path, filesystem);
2868
+ if (currentHash !== proof.hash) {
2869
+ recordGenerationProofFailure(failures, {
2870
+ domain: "graph",
2871
+ kind: "content-changed",
2872
+ path: proof.path,
2873
+ });
2874
+ }
2547
2875
  if (
2548
- proof === undefined ||
2549
- graphInputStateHash(proof.path, filesystem) !== proof.hash ||
2550
2876
  !sameHostInputRealpath(
2551
2877
  proof.realpath,
2552
2878
  hostInputRealpath(proof.path, filesystem),
2553
2879
  state.identityContext,
2554
2880
  )
2555
2881
  ) {
2556
- return false;
2882
+ recordGenerationProofFailure(failures, {
2883
+ domain: "graph",
2884
+ kind: "realpath-changed",
2885
+ path: proof.path,
2886
+ });
2557
2887
  }
2558
2888
  }
2559
- return true;
2889
+ return failures;
2560
2890
  }
2561
2891
 
2562
2892
  /** Compare one derived input with the snapshot that owned it at generation. */
@@ -2653,6 +2983,7 @@ function collectProjectInputSnapshot(
2653
2983
  projectDirectories: TtscProjectDirectorySnapshot[];
2654
2984
  provenSignatures: Record<string, string>;
2655
2985
  unstableFiles: Set<string>;
2986
+ walkFailures: TtscProjectWalkFailure[];
2656
2987
  } {
2657
2988
  const hashes: Record<string, string> = {};
2658
2989
  const fileSignatures: Record<string, string> = {};
@@ -2660,6 +2991,7 @@ function collectProjectInputSnapshot(
2660
2991
  const unstableFiles = new Set<string>();
2661
2992
  let attributed = true;
2662
2993
  const walked = walkProjectInputs(projectRoot, filesystem);
2994
+ const walkFailures = [...walked.failures];
2663
2995
  let complete = walked.complete;
2664
2996
  for (const file of walked.files) {
2665
2997
  try {
@@ -2691,6 +3023,7 @@ function collectProjectInputSnapshot(
2691
3023
  ) {
2692
3024
  complete = false;
2693
3025
  unstableFiles.add(key);
3026
+ walkFailures.push({ kind: "file-changed-during-read", path: file });
2694
3027
  } else {
2695
3028
  fileSignatures[key] = after;
2696
3029
  // Only a signature whose stamp's tick the filesystem's clock provably
@@ -2705,6 +3038,7 @@ function collectProjectInputSnapshot(
2705
3038
  // File watchers may observe a transform while another process is moving
2706
3039
  // or deleting files. The missing key invalidates older cache entries.
2707
3040
  complete = false;
3041
+ walkFailures.push({ kind: "file-read-failed", path: file });
2708
3042
  try {
2709
3043
  unstableFiles.add(toProjectKey(projectRoot, file, identities));
2710
3044
  } catch {
@@ -2722,6 +3056,7 @@ function collectProjectInputSnapshot(
2722
3056
  projectDirectories: walked.directories,
2723
3057
  provenSignatures,
2724
3058
  unstableFiles,
3059
+ walkFailures,
2725
3060
  };
2726
3061
  }
2727
3062
 
@@ -2739,10 +3074,12 @@ function walkProjectInputs(
2739
3074
  ): {
2740
3075
  complete: boolean;
2741
3076
  directories: TtscProjectDirectorySnapshot[];
3077
+ failures: TtscProjectWalkFailure[];
2742
3078
  files: string[];
2743
3079
  } {
2744
3080
  let complete = true;
2745
3081
  const directories: TtscProjectDirectorySnapshot[] = [];
3082
+ const failures: TtscProjectWalkFailure[] = [];
2746
3083
  const files: string[] = [];
2747
3084
  const stack = [root];
2748
3085
  while (stack.length !== 0) {
@@ -2750,6 +3087,10 @@ function walkProjectInputs(
2750
3087
  const before = projectDirectorySignature(current, filesystem);
2751
3088
  if (before === undefined) {
2752
3089
  complete = false;
3090
+ failures.push({
3091
+ kind: "directory-metadata-unavailable",
3092
+ path: current,
3093
+ });
2753
3094
  continue;
2754
3095
  }
2755
3096
  let entries: fs.Dirent[];
@@ -2757,11 +3098,19 @@ function walkProjectInputs(
2757
3098
  entries = filesystem.readdir(current);
2758
3099
  } catch {
2759
3100
  complete = false;
3101
+ failures.push({ kind: "directory-read-failed", path: current });
2760
3102
  continue;
2761
3103
  }
2762
3104
  const after = projectDirectorySignature(current, filesystem);
2763
3105
  if (after === undefined || before !== after) {
2764
3106
  complete = false;
3107
+ failures.push({
3108
+ kind:
3109
+ after === undefined
3110
+ ? "directory-metadata-unavailable"
3111
+ : "directory-changed-during-walk",
3112
+ path: current,
3113
+ });
2765
3114
  }
2766
3115
  directories.push({
2767
3116
  path: current,
@@ -2787,7 +3136,7 @@ function walkProjectInputs(
2787
3136
  }
2788
3137
  directories.sort((left, right) => left.path.localeCompare(right.path));
2789
3138
  files.sort();
2790
- return { complete, directories, files };
3139
+ return { complete, directories, failures, files };
2791
3140
  }
2792
3141
 
2793
3142
  /** Return a cheap identity for one directory's immediate membership. */
@@ -2862,6 +3211,8 @@ async function createProjectMutationTracker(
2862
3211
  filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
2863
3212
  ): Promise<TtscProjectMutationTracker> {
2864
3213
  const tracker: TtscProjectMutationTracker = {
3214
+ changes: new Set(),
3215
+ changesOmitted: false,
2865
3216
  close: () => undefined,
2866
3217
  failed: false,
2867
3218
  membershipChanged: false,
@@ -2886,8 +3237,15 @@ async function createProjectMutationTracker(
2886
3237
  openDirectoryWatch(
2887
3238
  filesystem,
2888
3239
  directory.path,
2889
- (eventType) => {
2890
- if (eventType === "rename") tracker.membershipChanged = true;
3240
+ (eventType, filename) => {
3241
+ if (eventType === "rename") {
3242
+ recordProjectMutation(
3243
+ tracker,
3244
+ filename === null
3245
+ ? directory.path
3246
+ : path.join(directory.path, filename),
3247
+ );
3248
+ }
2891
3249
  },
2892
3250
  () => {
2893
3251
  tracker.failed = true;
@@ -2939,6 +3297,8 @@ async function createHostInputMutationTracker(
2939
3297
  names: [...location.names],
2940
3298
  }));
2941
3299
  const tracker: TtscProjectMutationTracker = {
3300
+ changes: new Set(),
3301
+ changesOmitted: false,
2942
3302
  close: () => undefined,
2943
3303
  // Coverage is the caller's claim, and it is required rather than derived
2944
3304
  // from the input list: an input is watched by its exact name here, but only
@@ -2981,7 +3341,12 @@ async function createHostInputMutationTracker(
2981
3341
  ? null
2982
3342
  : normalizeHostInputName(filename, caseSensitive);
2983
3343
  if (reported === null || names.has(reported)) {
2984
- tracker.membershipChanged = true;
3344
+ recordProjectMutation(
3345
+ tracker,
3346
+ filename === null
3347
+ ? location.directory
3348
+ : path.join(location.directory, filename),
3349
+ );
2985
3350
  }
2986
3351
  },
2987
3352
  () => {
@@ -2996,6 +3361,20 @@ async function createHostInputMutationTracker(
2996
3361
  return tracker;
2997
3362
  }
2998
3363
 
3364
+ /** Record enough exact mutation evidence without retaining an event stream. */
3365
+ function recordProjectMutation(
3366
+ tracker: TtscProjectMutationTracker,
3367
+ changed: string,
3368
+ ): void {
3369
+ tracker.membershipChanged = true;
3370
+ if (tracker.changes.has(changed)) return;
3371
+ if (tracker.changes.size < MAX_GENERATION_MUTATION_PATHS) {
3372
+ tracker.changes.add(changed);
3373
+ } else {
3374
+ tracker.changesOmitted = true;
3375
+ }
3376
+ }
3377
+
2999
3378
  interface WindowsProjectMutationBroker {
3000
3379
  child: ChildProcess;
3001
3380
  /** Round-trips awaiting the child's reply, by request id. */
@@ -3128,8 +3507,10 @@ function getWindowsProjectMutationBroker(): WindowsProjectMutationBroker {
3128
3507
  child.on("message", (message: unknown) => {
3129
3508
  if (message === null || typeof message !== "object") return;
3130
3509
  const record = message as {
3510
+ directory?: string;
3131
3511
  drained?: boolean;
3132
3512
  failed?: boolean;
3513
+ filename?: string | null;
3133
3514
  id?: number;
3134
3515
  ready?: boolean;
3135
3516
  };
@@ -3147,7 +3528,16 @@ function getWindowsProjectMutationBroker(): WindowsProjectMutationBroker {
3147
3528
  if (record.failed === true) registration.tracker.failed = true;
3148
3529
  if (record.ready === true) registration.ready();
3149
3530
  if (record.ready !== true && record.failed !== true) {
3150
- registration.tracker.membershipChanged = true;
3531
+ if (typeof record.directory === "string") {
3532
+ recordProjectMutation(
3533
+ registration.tracker,
3534
+ typeof record.filename === "string"
3535
+ ? path.join(record.directory, record.filename)
3536
+ : record.directory,
3537
+ );
3538
+ } else {
3539
+ registration.tracker.membershipChanged = true;
3540
+ }
3151
3541
  }
3152
3542
  });
3153
3543
  windowsProjectMutationBroker = broker;
@@ -3238,7 +3628,7 @@ const WINDOWS_WATCH_BROKER_SOURCE = [
3238
3628
  " const names = location.names === undefined ? undefined : new Set(location.names.map((name) => name.toLowerCase()));",
3239
3629
  " const watcher = fs.watch(location.directory, { persistent: false }, (event, filename) => {",
3240
3630
  " const matches = names === undefined || filename === null || names.has(String(filename).toLowerCase());",
3241
- ' if (matches && (message.allEvents || event === "rename")) process.send?.({ id: message.id });',
3631
+ ' if (matches && (message.allEvents || event === "rename")) process.send?.({ directory: location.directory, filename: filename === null ? null : String(filename), id: message.id });',
3242
3632
  " });",
3243
3633
  ' watcher.on("error", () => process.send?.({ failed: true, id: message.id }));',
3244
3634
  " watchers.push(watcher);",
@@ -3483,12 +3873,13 @@ function matchesCachedExternalInputs(cached: TtscCachedProjectTransform): {
3483
3873
 
3484
3874
  /**
3485
3875
  * Derive the absolute out-of-walk input set of a whole project transform: the
3486
- * union of every reference-graph member (edge keys and targets, globals, the
3487
- * config chain) and every plugin-reported dependency, minus everything the
3488
- * project walk already hashes and the disposed temp-dir tsconfig. These are the
3489
- * inputs {@link matchesCachedSource}'s walk cannot see. Resolution candidates
3490
- * that are still missing remain in this set even under the project root: the
3491
- * first walk cannot hash a file that has not been created yet.
3876
+ * union of every transformed source key, reference-graph member (edge keys and
3877
+ * targets, globals, the config chain), and plugin-reported dependency, minus
3878
+ * everything the project walk already hashes and the disposed transform scratch
3879
+ * tree. These are the inputs {@link matchesCachedSource}'s walk cannot see.
3880
+ * Resolution candidates that are still missing remain in this set even under
3881
+ * the project root: the first walk cannot hash a file that has not been created
3882
+ * yet.
3492
3883
  *
3493
3884
  * A `dependenciesComplete` declaration deliberately does not narrow the stored
3494
3885
  * set: other files in the same whole-project result can still own the omitted
@@ -3500,6 +3891,7 @@ function selectExternalInputPaths(props: {
3500
3891
  filesystem?: TtscTransformFilesystemOperations;
3501
3892
  projectRoot: string;
3502
3893
  result: ITtscCompilerTransformation;
3894
+ scratchDirectory?: string;
3503
3895
  temporaryTsconfig?: string;
3504
3896
  }): string[] {
3505
3897
  if (props.result.type === "exception") {
@@ -3510,6 +3902,10 @@ function selectExternalInputPaths(props: {
3510
3902
  const identities = createHostPathIdentityContext(filesystem);
3511
3903
  const resolutionCandidates = new Set<string>();
3512
3904
  const graph = props.result.graph;
3905
+ // Every transform output key names the source file whose transformed text it
3906
+ // carries. Keep an out-of-walk source in the external snapshot instead of
3907
+ // injecting it into the project-walk key universe (samchon/ttsc#252).
3908
+ members.push(...Object.keys(props.result.typescript));
3513
3909
  if (graph !== undefined) {
3514
3910
  for (const [source, targets] of Object.entries(graph.edges ?? {})) {
3515
3911
  members.push(source);
@@ -3571,6 +3967,7 @@ function selectExternalInputPaths(props: {
3571
3967
  resolutionCandidates.has(identity) && !filesystem.exists(absolute);
3572
3968
  if (
3573
3969
  identity === excluded ||
3970
+ isTransformScratchInput(absolute, props.scratchDirectory) ||
3574
3971
  seen.has(spelling) ||
3575
3972
  (!missingCandidate &&
3576
3973
  isProjectWalkPath(props.projectRoot, absolute, identities, filesystem))
@@ -3607,6 +4004,7 @@ function selectNotifiableAbsentInputs(props: {
3607
4004
  filesystem: TtscTransformFilesystemOperations;
3608
4005
  projectRoot: string;
3609
4006
  result: ITtscCompilerTransformation;
4007
+ scratchDirectory?: string;
3610
4008
  temporaryTsconfig?: string;
3611
4009
  }): { candidates: string[]; watched: string[] } {
3612
4010
  const empty = { candidates: [], watched: [] };
@@ -3643,6 +4041,7 @@ function selectNotifiableAbsentInputs(props: {
3643
4041
  const spelling = path.resolve(absolute);
3644
4042
  if (
3645
4043
  seen.has(spelling) ||
4044
+ isTransformScratchInput(absolute, props.scratchDirectory) ||
3646
4045
  (excluded !== undefined &&
3647
4046
  pathIdentityKey(absolute, identities) === excluded) ||
3648
4047
  props.filesystem.exists(absolute)
@@ -3825,7 +4224,7 @@ function walkSnapshotComplete(
3825
4224
  snapshot: {
3826
4225
  complete: boolean;
3827
4226
  directoryComplete: boolean;
3828
- unstableFiles: Set<string>;
4227
+ unstableFiles: ReadonlySet<string>;
3829
4228
  },
3830
4229
  declared: ReadonlySet<string> | undefined,
3831
4230
  ): boolean {
@@ -3841,6 +4240,136 @@ function walkSnapshotComplete(
3841
4240
  return true;
3842
4241
  }
3843
4242
 
4243
+ /** Preserve exact project-walk and mutation witnesses for one failed attempt. */
4244
+ function recordProjectSnapshotFailures(
4245
+ failures: TtscGenerationProofFailures,
4246
+ props: {
4247
+ before: ReturnType<typeof collectProjectInputSnapshot>;
4248
+ candidateTracker?: TtscProjectMutationTracker;
4249
+ declared: ReadonlySet<string> | undefined;
4250
+ hostInputTracker?: TtscProjectMutationTracker;
4251
+ identities: FilesystemPathIdentityContext;
4252
+ projectRoot: string;
4253
+ snapshot: ReturnType<typeof collectProjectInputSnapshot>;
4254
+ tracker?: TtscProjectMutationTracker;
4255
+ },
4256
+ ): void {
4257
+ const recordWalk = (
4258
+ snapshot: ReturnType<typeof collectProjectInputSnapshot>,
4259
+ ): void => {
4260
+ for (const failure of snapshot.walkFailures) {
4261
+ if (failure.kind.startsWith("file-") && props.declared !== undefined) {
4262
+ try {
4263
+ const key = toProjectKey(
4264
+ props.projectRoot,
4265
+ failure.path,
4266
+ props.identities,
4267
+ );
4268
+ if (!props.declared.has(key)) continue;
4269
+ } catch {
4270
+ // An unidentifiable failed input taints the complete project walk.
4271
+ }
4272
+ }
4273
+ recordGenerationProofFailure(failures, {
4274
+ domain: "project",
4275
+ kind: failure.kind,
4276
+ path: failure.path,
4277
+ });
4278
+ }
4279
+ };
4280
+ recordWalk(props.before);
4281
+ recordWalk(props.snapshot);
4282
+
4283
+ const keys =
4284
+ props.declared ??
4285
+ new Set([
4286
+ ...Object.keys(props.before.hashes),
4287
+ ...Object.keys(props.snapshot.hashes),
4288
+ ]);
4289
+ for (const key of keys) {
4290
+ if (props.before.hashes[key] !== props.snapshot.hashes[key]) {
4291
+ recordGenerationProofFailure(failures, {
4292
+ domain: "project",
4293
+ kind: "input-content-changed",
4294
+ path: path.resolve(props.projectRoot, key),
4295
+ });
4296
+ }
4297
+ if (
4298
+ props.before.fileSignatures[key] !== props.snapshot.fileSignatures[key]
4299
+ ) {
4300
+ recordGenerationProofFailure(failures, {
4301
+ domain: "project",
4302
+ kind: "input-metadata-changed",
4303
+ path: path.resolve(props.projectRoot, key),
4304
+ });
4305
+ }
4306
+ }
4307
+
4308
+ const leftDirectories = new Map(
4309
+ props.before.projectDirectories.map((entry) => [
4310
+ entry.path,
4311
+ entry.signature,
4312
+ ]),
4313
+ );
4314
+ const rightDirectories = new Map(
4315
+ props.snapshot.projectDirectories.map((entry) => [
4316
+ entry.path,
4317
+ entry.signature,
4318
+ ]),
4319
+ );
4320
+ for (const directory of new Set([
4321
+ ...leftDirectories.keys(),
4322
+ ...rightDirectories.keys(),
4323
+ ])) {
4324
+ if (leftDirectories.get(directory) !== rightDirectories.get(directory)) {
4325
+ recordGenerationProofFailure(failures, {
4326
+ domain: "project",
4327
+ kind: "directory-membership-changed",
4328
+ path: directory,
4329
+ });
4330
+ }
4331
+ }
4332
+
4333
+ const recordTracker = (
4334
+ tracker: TtscProjectMutationTracker | undefined,
4335
+ kind: string,
4336
+ ): void => {
4337
+ if (tracker?.membershipChanged !== true) return;
4338
+ if (tracker.changes.size === 0) {
4339
+ recordGenerationProofFailure(failures, {
4340
+ domain: "project",
4341
+ kind,
4342
+ path: props.projectRoot,
4343
+ });
4344
+ return;
4345
+ }
4346
+ for (const changed of tracker.changes) {
4347
+ recordGenerationProofFailure(failures, {
4348
+ domain: "project",
4349
+ kind,
4350
+ path: changed,
4351
+ });
4352
+ }
4353
+ if (tracker.changesOmitted) {
4354
+ failures.omitted = Math.min(
4355
+ Number.MAX_SAFE_INTEGER,
4356
+ failures.omitted + 1,
4357
+ );
4358
+ }
4359
+ };
4360
+ recordTracker(props.tracker, "project-membership-event");
4361
+ recordTracker(props.hostInputTracker, "host-input-event");
4362
+ recordTracker(props.candidateTracker, "candidate-event");
4363
+
4364
+ if (failures.entries.length === 0) {
4365
+ recordGenerationProofFailure(failures, {
4366
+ domain: "project",
4367
+ kind: "snapshot-incomplete",
4368
+ path: props.projectRoot,
4369
+ });
4370
+ }
4371
+ }
4372
+
3844
4373
  /** {@link selectDeclaredProjectInputKeys} memoized per envelope generation. */
3845
4374
  function declaredProjectInputKeys(
3846
4375
  state: TtscEnvelopeDerivation,
@@ -3851,6 +4380,7 @@ function declaredProjectInputKeys(
3851
4380
  identities: state.identityContext,
3852
4381
  projectRoot: cached.projectRoot,
3853
4382
  result: cached.result,
4383
+ scratchDirectory: cached.scratchDirectory,
3854
4384
  });
3855
4385
  state.declaredInputKeysBuilt = true;
3856
4386
  }
@@ -3867,6 +4397,7 @@ function selectDeclaredProjectInputKeys(props: {
3867
4397
  identities: FilesystemPathIdentityContext;
3868
4398
  projectRoot: string;
3869
4399
  result: ITtscCompilerTransformation;
4400
+ scratchDirectory?: string;
3870
4401
  }): Set<string> | undefined {
3871
4402
  if (props.result.type === "exception" || props.result.graph === undefined) {
3872
4403
  return undefined;
@@ -3875,13 +4406,9 @@ function selectDeclaredProjectInputKeys(props: {
3875
4406
  const keys = new Set<string>();
3876
4407
  const add = (entry: unknown): void => {
3877
4408
  if (typeof entry !== "string" || entry.length === 0) return;
3878
- keys.add(
3879
- toProjectKey(
3880
- props.projectRoot,
3881
- path.resolve(props.projectRoot, entry),
3882
- props.identities,
3883
- ),
3884
- );
4409
+ const absolute = path.resolve(props.projectRoot, entry);
4410
+ if (isTransformScratchInput(absolute, props.scratchDirectory)) return;
4411
+ keys.add(toProjectKey(props.projectRoot, absolute, props.identities));
3885
4412
  };
3886
4413
  for (const [source, targets] of Object.entries(graph.edges ?? {})) {
3887
4414
  add(source);
@@ -3905,52 +4432,288 @@ function selectDeclaredProjectInputKeys(props: {
3905
4432
  return keys;
3906
4433
  }
3907
4434
 
3908
- /**
3909
- * Project roots already told they cannot reuse a compile, so a build reports
3910
- * the condition once instead of once per module.
3911
- */
3912
- const REPORTED_UNREUSABLE_GENERATIONS = new Set<string>();
4435
+ /** Create an empty bounded witness collection for one transform attempt. */
4436
+ function createGenerationProofFailures(): TtscGenerationProofFailures {
4437
+ return { entries: [], omitted: 0, seen: new Set() };
4438
+ }
4439
+
4440
+ /** Retain one unique proof witness without allowing diagnostics to grow freely. */
4441
+ function recordGenerationProofFailure(
4442
+ failures: TtscGenerationProofFailures,
4443
+ failure: TtscGenerationProofFailure,
4444
+ ): void {
4445
+ const key = JSON.stringify([
4446
+ failure.domain,
4447
+ failure.kind,
4448
+ failure.path,
4449
+ failure.detail,
4450
+ ]);
4451
+ if (failures.seen.has(key)) return;
4452
+ if (failures.entries.length < MAX_GENERATION_PROOF_FAILURES) {
4453
+ // `seen` follows the same bound as `entries`: retaining every discarded
4454
+ // identity would make a bounded diagnostic an unbounded memory sink.
4455
+ failures.seen.add(key);
4456
+ failures.entries.push(failure);
4457
+ } else {
4458
+ failures.omitted = Math.min(Number.MAX_SAFE_INTEGER, failures.omitted + 1);
4459
+ }
4460
+ }
4461
+
4462
+ /** Fold one bounded witness collection into another. */
4463
+ function mergeGenerationProofFailures(
4464
+ target: TtscGenerationProofFailures,
4465
+ source: TtscGenerationProofFailures,
4466
+ ): void {
4467
+ for (const failure of source.entries) {
4468
+ recordGenerationProofFailure(target, failure);
4469
+ }
4470
+ target.omitted = Math.min(
4471
+ Number.MAX_SAFE_INTEGER,
4472
+ target.omitted + source.omitted,
4473
+ );
4474
+ }
4475
+
4476
+ /** Hash the declared-input-relevant failure shape without retaining it. */
4477
+ function projectWalkFailureFingerprint(
4478
+ snapshot: {
4479
+ complete: boolean;
4480
+ directoryComplete: boolean;
4481
+ unstableFiles: ReadonlySet<string>;
4482
+ walkFailures: readonly TtscProjectWalkFailure[];
4483
+ },
4484
+ declared: ReadonlySet<string> | undefined,
4485
+ projectRoot: string,
4486
+ identities: FilesystemPathIdentityContext,
4487
+ ): string {
4488
+ const relevantUnstableFiles =
4489
+ declared === undefined
4490
+ ? [...snapshot.unstableFiles]
4491
+ : [...snapshot.unstableFiles].filter((key) => declared.has(key));
4492
+ const relevantFailures = snapshot.walkFailures.filter((failure) => {
4493
+ if (!failure.kind.startsWith("file-")) return true;
4494
+ if (declared === undefined) return true;
4495
+ try {
4496
+ return declared.has(toProjectKey(projectRoot, failure.path, identities));
4497
+ } catch {
4498
+ return true;
4499
+ }
4500
+ });
4501
+ return hashText(
4502
+ JSON.stringify({
4503
+ complete: walkSnapshotComplete(snapshot, declared),
4504
+ directoryComplete: snapshot.directoryComplete,
4505
+ failures: relevantFailures
4506
+ .map((failure) => `${failure.kind}\0${path.resolve(failure.path)}`)
4507
+ .sort(),
4508
+ unstableFiles: relevantUnstableFiles.sort(),
4509
+ }),
4510
+ );
4511
+ }
4512
+
4513
+ /** Compact state of one exact out-of-walk input in a failed generation. */
4514
+ function failedGenerationInputState(
4515
+ input: string,
4516
+ filesystem: TtscTransformFilesystemOperations,
4517
+ ): string {
4518
+ let directory = "not-directory";
4519
+ try {
4520
+ if (filesystem.stat(input).isDirectory()) {
4521
+ directory = hashText(
4522
+ filesystem
4523
+ .readdir(input)
4524
+ .map((entry) =>
4525
+ [
4526
+ entry.name,
4527
+ entry.isDirectory(),
4528
+ entry.isFile(),
4529
+ entry.isSymbolicLink(),
4530
+ ].join(":"),
4531
+ )
4532
+ .sort()
4533
+ .join("\0"),
4534
+ );
4535
+ }
4536
+ } catch {
4537
+ directory = "unavailable";
4538
+ }
4539
+ return hashText(
4540
+ JSON.stringify([
4541
+ inputMetadataSignature(input, filesystem) ?? "missing",
4542
+ hostInputStateHash(input, filesystem) ?? MISSING_INPUT_STATE,
4543
+ hostInputRealpath(input, filesystem),
4544
+ directory,
4545
+ ]),
4546
+ );
4547
+ }
4548
+
4549
+ /** Snapshot every input outside the project walk that could change a retry. */
4550
+ function captureFailedGenerationInputStates(
4551
+ cached: TtscCachedProjectTransform,
4552
+ failures: TtscGenerationProofFailures,
4553
+ ): ReadonlyMap<string, string> {
4554
+ const filesystem = resultFilesystem(cached.result);
4555
+ const inputs = new Set(
4556
+ (cached.externalInputPaths ?? []).map((input) => path.resolve(input)),
4557
+ );
4558
+ for (const input of selectPersistentHostInputs({
4559
+ filesystem,
4560
+ projectRoot: cached.projectRoot,
4561
+ result: cached.result,
4562
+ scratchDirectory: cached.scratchDirectory,
4563
+ temporaryTsconfig: cached.temporaryTsconfig,
4564
+ })) {
4565
+ inputs.add(path.resolve(input));
4566
+ }
4567
+ for (const failure of failures.entries) {
4568
+ if (failure.path !== undefined) inputs.add(path.resolve(failure.path));
4569
+ }
4570
+ return new Map(
4571
+ [...inputs]
4572
+ .sort()
4573
+ .map((input) => [input, failedGenerationInputState(input, filesystem)]),
4574
+ );
4575
+ }
4576
+
4577
+ /** Capture source baselines for project and out-of-walk transform outputs. */
4578
+ function captureTransformSourceHashes(
4579
+ cached: TtscCachedProjectTransform,
4580
+ currentFile: string,
4581
+ currentSourceHash: string,
4582
+ ): Record<string, string> {
4583
+ const filesystem = resultFilesystem(cached.result);
4584
+ const identities = envelopeDerivation(cached).identityContext;
4585
+ const hashes: Record<string, string> = {};
4586
+ if (cached.result.type === "success") {
4587
+ for (const output of Object.keys(cached.result.typescript)) {
4588
+ const file = path.resolve(cached.projectRoot, output);
4589
+ const hash = hostInputStateHash(file, filesystem);
4590
+ if (hash !== null) hashes[pathIdentityKey(file, identities)] = hash;
4591
+ }
4592
+ }
4593
+ hashes[pathIdentityKey(currentFile, identities)] = currentSourceHash;
4594
+ return hashes;
4595
+ }
3913
4596
 
3914
4597
  /**
3915
- * Report, once per project root, that a generation cannot be reused.
4598
+ * Whether a terminal proof failure's observed environment actually changed.
3916
4599
  *
3917
- * Every module of the build then recompiles the whole project, so the condition
3918
- * is the difference between one compile and one compile per module. It stayed
3919
- * invisible for the whole life of samchon/ttsc#970: consumers saw only a build
3920
- * that never finished, and each investigation had to rediscover the cause from
3921
- * outside. A named reason turns the next occurrence into a bug report instead
3922
- * of an archaeology session.
4600
+ * This is deliberately a confirmation test: inability to re-probe retains the
4601
+ * old verdict instead of turning every module request into another compile.
4602
+ * Cache lifecycle reset remains the unconditional recovery boundary.
3923
4603
  */
3924
- function reportUnreusableGeneration(
3925
- cached: TtscCachedProjectTransform,
3926
- evidence: {
3927
- externalInputs: boolean;
3928
- graphProofs: boolean;
3929
- universalInputs: boolean;
3930
- walkStable: boolean;
4604
+ function failedGenerationEnvironmentChanged(
4605
+ validation: TtscFailedGenerationValidation,
4606
+ props: {
4607
+ currentFile: string;
4608
+ currentSource: string;
4609
+ filesystem: TtscTransformFilesystemOperations;
3931
4610
  },
3932
- ): void {
3933
- const missing = [
3934
- ...(evidence.walkStable ? [] : ["a stable project snapshot"]),
3935
- ...(evidence.graphProofs ? [] : ["compiler proofs for its graph inputs"]),
3936
- ...(evidence.externalInputs
3937
- ? []
3938
- : ["a complete out-of-walk input snapshot"]),
3939
- ...(evidence.universalInputs ? [] : ["a universal host-input manifest"]),
3940
- ];
3941
- const key = `${cached.projectRoot}\0${missing.join(",")}`;
3942
- if (REPORTED_UNREUSABLE_GENERATIONS.has(key)) {
3943
- return;
4611
+ ): boolean {
4612
+ try {
4613
+ const identities = envelopeDerivation(validation.cached).identityContext;
4614
+ const currentSourceHash = hashText(props.currentSource);
4615
+ const expectedSourceHash =
4616
+ validation.cached.sourceHashes?.[
4617
+ pathIdentityKey(props.currentFile, identities)
4618
+ ];
4619
+ if (
4620
+ expectedSourceHash !== undefined &&
4621
+ expectedSourceHash !== currentSourceHash
4622
+ ) {
4623
+ return true;
4624
+ }
4625
+ const current = collectProjectInputSnapshot(
4626
+ validation.cached.projectRoot,
4627
+ identities,
4628
+ props.filesystem,
4629
+ );
4630
+ if (
4631
+ validation.projectWalkComplete !==
4632
+ walkSnapshotComplete(current, validation.declaredInputs) ||
4633
+ validation.projectWalkFailures !==
4634
+ projectWalkFailureFingerprint(
4635
+ current,
4636
+ validation.declaredInputs,
4637
+ validation.cached.projectRoot,
4638
+ identities,
4639
+ ) ||
4640
+ !sameHashes(
4641
+ validation.projectInputHashes,
4642
+ current.hashes,
4643
+ validation.declaredInputs,
4644
+ ) ||
4645
+ !sameProjectDirectories(
4646
+ validation.cached.projectDirectories ?? [],
4647
+ current.projectDirectories,
4648
+ )
4649
+ ) {
4650
+ return true;
4651
+ }
4652
+ for (const [input, recorded] of validation.inputStates) {
4653
+ if (failedGenerationInputState(input, props.filesystem) !== recorded) {
4654
+ return true;
4655
+ }
4656
+ }
4657
+ return false;
4658
+ } catch {
4659
+ return false;
3944
4660
  }
3945
- REPORTED_UNREUSABLE_GENERATIONS.add(key);
3946
- process.stderr.write(
3947
- `ttsc: the transform cache cannot reuse this project's compile, so every ` +
3948
- `module recompiles the whole project.\n` +
3949
- ` project: ${cached.projectRoot}\n` +
3950
- ` missing: ${missing.join("; ")}\n` +
3951
- ` Please report this at https://github.com/samchon/ttsc/issues with ` +
3952
- `this message.\n`,
4661
+ }
4662
+
4663
+ /** Render one input without leaking source content or control characters. */
4664
+ function formatGenerationFailurePath(
4665
+ projectRoot: string,
4666
+ input: string,
4667
+ ): string {
4668
+ const absolute = path.resolve(input);
4669
+ const relative = path.relative(projectRoot, absolute);
4670
+ const display =
4671
+ relative === ""
4672
+ ? "."
4673
+ : relative !== ".." &&
4674
+ !relative.startsWith(`..${path.sep}`) &&
4675
+ !path.isAbsolute(relative)
4676
+ ? relative
4677
+ : absolute;
4678
+ return JSON.stringify(display.split(path.sep).join("/"));
4679
+ }
4680
+
4681
+ /** Build the terminal error shared by every waiter of an unstable generation. */
4682
+ function createUnstableGenerationError(
4683
+ projectRoot: string,
4684
+ attempts: readonly TtscGenerationProofFailures[],
4685
+ validation: TtscFailedGenerationValidation,
4686
+ ): TtscUnstableGenerationError {
4687
+ const lines = [
4688
+ `ttsc: could not capture a reusable transform generation after ${attempts.length} attempts.`,
4689
+ ` project: ${projectRoot}`,
4690
+ ];
4691
+ attempts.forEach((failures, index) => {
4692
+ lines.push(` attempt ${index + 1}:`);
4693
+ if (failures.entries.length === 0) {
4694
+ lines.push(" - project/generation-proof-incomplete");
4695
+ }
4696
+ for (const failure of failures.entries) {
4697
+ const input =
4698
+ failure.path === undefined
4699
+ ? ""
4700
+ : `: ${formatGenerationFailurePath(projectRoot, failure.path)}`;
4701
+ const detail =
4702
+ failure.detail === undefined
4703
+ ? ""
4704
+ : ` (producer: ${JSON.stringify(failure.detail)})`;
4705
+ lines.push(` - ${failure.domain}/${failure.kind}${input}${detail}`);
4706
+ }
4707
+ if (failures.omitted !== 0) {
4708
+ lines.push(
4709
+ ` - ... ${failures.omitted} additional witness(es) omitted`,
4710
+ );
4711
+ }
4712
+ });
4713
+ lines.push(
4714
+ " Stop writes to the listed inputs before compilation, or fix the producer that omitted or contradicted the listed proof.",
3953
4715
  );
4716
+ return new TtscUnstableGenerationError(lines.join("\n"), validation);
3954
4717
  }
3955
4718
 
3956
4719
  function hashText(input: string | Buffer): string {
@@ -3966,6 +4729,52 @@ async function transformProject(props: {
3966
4729
  plugins?: ResolvedTtscUnpluginOptions["plugins"];
3967
4730
  trackProjectMembership: boolean;
3968
4731
  tsconfig: string;
4732
+ }): Promise<TtscCachedProjectTransform> {
4733
+ const attempts: TtscGenerationProofFailures[] = [];
4734
+ for (let attempt = 0; attempt < TRANSFORM_GENERATION_ATTEMPTS; attempt += 1) {
4735
+ const cached = await captureTransformGeneration(props);
4736
+ if (
4737
+ !props.trackProjectMembership ||
4738
+ cached.result.type !== "success" ||
4739
+ cached.projectSnapshotComplete === true
4740
+ ) {
4741
+ return cached;
4742
+ }
4743
+ attempts.push(
4744
+ TRANSFORM_GENERATION_FAILURES.get(cached.result) ??
4745
+ createGenerationProofFailures(),
4746
+ );
4747
+ if (attempt + 1 === TRANSFORM_GENERATION_ATTEMPTS) {
4748
+ const validation = TRANSFORM_FAILED_GENERATION_VALIDATIONS.get(
4749
+ cached.result,
4750
+ );
4751
+ if (validation === undefined) {
4752
+ disposeCachedTransform(cached);
4753
+ throw new Error(
4754
+ "ttsc: failed transform generation has no retry validation baseline",
4755
+ );
4756
+ }
4757
+ throw createUnstableGenerationError(
4758
+ path.dirname(props.tsconfig),
4759
+ attempts,
4760
+ validation,
4761
+ );
4762
+ }
4763
+ disposeCachedTransform(cached);
4764
+ }
4765
+ throw new Error("ttsc: transform generation retry loop did not terminate");
4766
+ }
4767
+
4768
+ /** Capture one whole-project transform attempt and all of its reuse proofs. */
4769
+ async function captureTransformGeneration(props: {
4770
+ aliasPaths: Record<string, string[]>;
4771
+ compilerOptions: Record<string, unknown>;
4772
+ currentFile: string;
4773
+ currentSource: string;
4774
+ filesystem: TtscTransformFilesystemOperations;
4775
+ plugins?: ResolvedTtscUnpluginOptions["plugins"];
4776
+ trackProjectMembership: boolean;
4777
+ tsconfig: string;
3969
4778
  }): Promise<TtscCachedProjectTransform> {
3970
4779
  const projectRoot = path.dirname(props.tsconfig);
3971
4780
  const scratchDirectory = createTransformScratchDirectory(
@@ -4019,6 +4828,7 @@ async function transformProject(props: {
4019
4828
  filesystem: props.filesystem,
4020
4829
  projectRoot,
4021
4830
  result,
4831
+ scratchDirectory,
4022
4832
  temporaryTsconfig,
4023
4833
  });
4024
4834
  // The generation's absent resolution candidates, which get a watcher of
@@ -4034,6 +4844,7 @@ async function transformProject(props: {
4034
4844
  filesystem: props.filesystem,
4035
4845
  projectRoot,
4036
4846
  result,
4847
+ scratchDirectory,
4037
4848
  temporaryTsconfig,
4038
4849
  })
4039
4850
  : { candidates: [], watched: [] };
@@ -4067,6 +4878,7 @@ async function transformProject(props: {
4067
4878
  filesystem: props.filesystem,
4068
4879
  projectRoot,
4069
4880
  result,
4881
+ scratchDirectory,
4070
4882
  temporaryTsconfig,
4071
4883
  });
4072
4884
  const inputSnapshot = collectProjectInputSnapshot(
@@ -4083,6 +4895,7 @@ async function transformProject(props: {
4083
4895
  identities,
4084
4896
  projectRoot,
4085
4897
  result,
4898
+ scratchDirectory,
4086
4899
  });
4087
4900
  const walkStable =
4088
4901
  walkSnapshotComplete(before, declaredInputs) &&
@@ -4111,14 +4924,21 @@ async function transformProject(props: {
4111
4924
  props.currentFile,
4112
4925
  identities,
4113
4926
  );
4114
- inputSnapshot.hashes[currentFileKey] = hashText(props.currentSource);
4115
- // That overlay makes this one key the only recorded hash a disk signature
4116
- // cannot stand for: the bytes it names came from the bundler, not the file.
4117
- delete inputSnapshot.provenSignatures[currentFileKey];
4927
+ const currentSourceHash = hashText(props.currentSource);
4928
+ const projectInputHashes = { ...inputSnapshot.hashes };
4929
+ if (
4930
+ Object.prototype.hasOwnProperty.call(inputSnapshot.hashes, currentFileKey)
4931
+ ) {
4932
+ inputSnapshot.hashes[currentFileKey] = currentSourceHash;
4933
+ // That overlay makes this one key the only recorded hash a disk signature
4934
+ // cannot stand for: the bytes it names came from the bundler, not the file.
4935
+ delete inputSnapshot.provenSignatures[currentFileKey];
4936
+ }
4118
4937
  const cached: TtscCachedProjectTransform = {
4119
4938
  // Capture the out-of-walk input hashes while the generation is fresh so
4120
4939
  // cache validation can re-check them; computed before dispose so the
4121
- // exclusion of the temp-dir tsconfig is the only reason it never keys.
4940
+ // scratch-tree exclusion is the only reason its disposed artifacts never
4941
+ // key the persistent generation.
4122
4942
  externalInputHashes: {},
4123
4943
  externalInputRealpaths: {},
4124
4944
  externalInputPaths,
@@ -4128,12 +4948,18 @@ async function transformProject(props: {
4128
4948
  projectSnapshotComplete: false,
4129
4949
  projectRoot,
4130
4950
  result,
4951
+ scratchDirectory,
4131
4952
  servedFiles: new Set(),
4132
4953
  // Remember the generated temp-dir tsconfig (disposed below) so watch
4133
4954
  // derivation can drop it from the envelope's config chain; a registered
4134
4955
  // but deleted file would invalidate every persistent-cache snapshot.
4135
4956
  ...(temporaryTsconfig === undefined ? {} : { temporaryTsconfig }),
4136
4957
  };
4958
+ cached.sourceHashes = captureTransformSourceHashes(
4959
+ cached,
4960
+ props.currentFile,
4961
+ currentSourceHash,
4962
+ );
4137
4963
  const externalInputSnapshot = captureExternalInputSnapshot(
4138
4964
  cached,
4139
4965
  externalInputPaths,
@@ -4145,23 +4971,52 @@ async function transformProject(props: {
4145
4971
  // cannot be reused can say which evidence it lacked. The extra work runs
4146
4972
  // only on the failing path, where the alternative is recompiling the whole
4147
4973
  // project for every remaining module.
4148
- const graphProofs = matchesCompilerGraphInputProofs(cached);
4149
- const universalInputs =
4150
- captureUniversalHostInputValidation(cached, props.currentFile) !==
4151
- undefined;
4974
+ const failures = createGenerationProofFailures();
4975
+ if (!walkStable) {
4976
+ recordProjectSnapshotFailures(failures, {
4977
+ before,
4978
+ candidateTracker,
4979
+ declared: declaredInputs,
4980
+ hostInputTracker,
4981
+ identities,
4982
+ projectRoot,
4983
+ snapshot: inputSnapshot,
4984
+ tracker,
4985
+ });
4986
+ }
4987
+ const graphFailures = compilerGraphInputProofFailures(cached);
4988
+ mergeGenerationProofFailures(failures, graphFailures);
4989
+ mergeGenerationProofFailures(failures, externalInputSnapshot.failures);
4990
+ const universalInputCapture = captureUniversalHostInputValidation(
4991
+ cached,
4992
+ props.currentFile,
4993
+ );
4994
+ mergeGenerationProofFailures(failures, universalInputCapture.failures);
4995
+ const graphProofs =
4996
+ graphFailures.entries.length === 0 && graphFailures.omitted === 0;
4997
+ const universalInputs = universalInputCapture.validation !== undefined;
4152
4998
  const stableProjectSnapshot =
4153
4999
  walkStable &&
4154
5000
  graphProofs &&
4155
5001
  externalInputSnapshot.complete &&
4156
5002
  universalInputs;
4157
- // Only a caching host loses anything here: without a cache every delivery
4158
- // compiles by design, so an unprovable generation costs it nothing.
4159
- if (!stableProjectSnapshot && props.trackProjectMembership) {
4160
- reportUnreusableGeneration(cached, {
4161
- externalInputs: externalInputSnapshot.complete,
4162
- graphProofs,
4163
- universalInputs,
4164
- walkStable,
5003
+ if (!stableProjectSnapshot) {
5004
+ TRANSFORM_GENERATION_FAILURES.set(result, failures);
5005
+ TRANSFORM_FAILED_GENERATION_VALIDATIONS.set(result, {
5006
+ cached,
5007
+ declaredInputs,
5008
+ inputStates: captureFailedGenerationInputStates(cached, failures),
5009
+ projectInputHashes,
5010
+ projectWalkComplete: walkSnapshotComplete(
5011
+ inputSnapshot,
5012
+ declaredInputs,
5013
+ ),
5014
+ projectWalkFailures: projectWalkFailureFingerprint(
5015
+ inputSnapshot,
5016
+ declaredInputs,
5017
+ projectRoot,
5018
+ identities,
5019
+ ),
4165
5020
  });
4166
5021
  }
4167
5022
  cached.projectSnapshotComplete = stableProjectSnapshot;
@@ -4209,21 +5064,30 @@ async function transformProject(props: {
4209
5064
  }
4210
5065
  }
4211
5066
 
4212
- /** Exclude the disposed overlay tsconfig from live host-input tracking. */
5067
+ /** Exclude disposed transform scratch from live host-input tracking. */
4213
5068
  function selectPersistentHostInputs(props: {
4214
5069
  filesystem: TtscTransformFilesystemOperations;
4215
5070
  projectRoot: string;
4216
5071
  result: ITtscCompilerTransformation;
5072
+ scratchDirectory?: string;
4217
5073
  temporaryTsconfig?: string;
4218
5074
  }): string[] {
4219
5075
  if (props.result.type === "exception") return [];
4220
5076
  const inputs = selectListedFiles(props.projectRoot, props.result.hostInputs);
4221
- if (props.temporaryTsconfig === undefined) return inputs;
5077
+ if (
5078
+ props.scratchDirectory === undefined &&
5079
+ props.temporaryTsconfig === undefined
5080
+ )
5081
+ return inputs;
4222
5082
  const identities = createHostPathIdentityContext(props.filesystem);
4223
- const temporary = pathIdentityKey(props.temporaryTsconfig, identities);
4224
- return inputs.filter(
4225
- (input) => pathIdentityKey(input, identities) !== temporary,
4226
- );
5083
+ const temporary =
5084
+ props.temporaryTsconfig === undefined
5085
+ ? undefined
5086
+ : pathIdentityKey(props.temporaryTsconfig, identities);
5087
+ return inputs.filter((input) => {
5088
+ if (isTransformScratchInput(input, props.scratchDirectory)) return false;
5089
+ return pathIdentityKey(input, identities) !== temporary;
5090
+ });
4227
5091
  }
4228
5092
 
4229
5093
  function createTransformTsconfig(
@@ -4344,6 +5208,17 @@ function pathIsWithin(child: string, parent: string): boolean {
4344
5208
  );
4345
5209
  }
4346
5210
 
5211
+ /** Whether an input is owned by the disposable transform scratch tree. */
5212
+ function isTransformScratchInput(
5213
+ input: string,
5214
+ scratchDirectory: string | undefined,
5215
+ ): boolean {
5216
+ return (
5217
+ scratchDirectory !== undefined &&
5218
+ pathIsWithin(path.resolve(input), path.resolve(scratchDirectory))
5219
+ );
5220
+ }
5221
+
4347
5222
  /** Route all compiler/plugin scratch to one owned directory outside project. */
4348
5223
  function transformScratchEnvironment(directory: string): NodeJS.ProcessEnv {
4349
5224
  return {