@ttsc/unplugin 0.28.5 → 0.29.0

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 (86) hide show
  1. package/README.md +15 -11
  2. package/lib/api.js +10 -0
  3. package/lib/api.js.map +1 -1
  4. package/lib/api.mjs +3 -2
  5. package/lib/api.mjs.map +1 -1
  6. package/lib/bun-register.d.cts +10 -7
  7. package/lib/bun-register.d.mts +10 -7
  8. package/lib/bun-register.d.ts +10 -7
  9. package/lib/bun-register.js +90 -24
  10. package/lib/bun-register.js.map +1 -1
  11. package/lib/bun-register.mjs +90 -24
  12. package/lib/bun-register.mjs.map +1 -1
  13. package/lib/bun.d.cts +10 -8
  14. package/lib/bun.d.mts +10 -8
  15. package/lib/bun.d.ts +10 -8
  16. package/lib/bun.js +19 -17
  17. package/lib/bun.js.map +1 -1
  18. package/lib/bun.mjs +20 -18
  19. package/lib/bun.mjs.map +1 -1
  20. package/lib/core/index.d.cts +10 -8
  21. package/lib/core/index.d.mts +10 -8
  22. package/lib/core/index.d.ts +10 -8
  23. package/lib/core/index.js +88 -16
  24. package/lib/core/index.js.map +1 -1
  25. package/lib/core/index.mjs +81 -18
  26. package/lib/core/index.mjs.map +1 -1
  27. package/lib/core/projectDiscovery.d.cts +55 -0
  28. package/lib/core/projectDiscovery.d.mts +55 -0
  29. package/lib/core/projectDiscovery.d.ts +55 -0
  30. package/lib/core/projectDiscovery.js +187 -0
  31. package/lib/core/projectDiscovery.js.map +1 -0
  32. package/lib/core/projectDiscovery.mjs +182 -0
  33. package/lib/core/projectDiscovery.mjs.map +1 -0
  34. package/lib/core/sourceExtensions.d.cts +10 -0
  35. package/lib/core/sourceExtensions.d.mts +10 -0
  36. package/lib/core/sourceExtensions.d.ts +10 -0
  37. package/lib/core/sourceExtensions.js +38 -0
  38. package/lib/core/sourceExtensions.js.map +1 -0
  39. package/lib/core/sourceExtensions.mjs +32 -0
  40. package/lib/core/sourceExtensions.mjs.map +1 -0
  41. package/lib/core/transform.d.cts +96 -16
  42. package/lib/core/transform.d.mts +96 -16
  43. package/lib/core/transform.d.ts +96 -16
  44. package/lib/core/transform.js +1295 -309
  45. package/lib/core/transform.js.map +1 -1
  46. package/lib/core/transform.mjs +1290 -310
  47. package/lib/core/transform.mjs.map +1 -1
  48. package/lib/core/tsconfigPaths.d.cts +55 -3
  49. package/lib/core/tsconfigPaths.d.mts +55 -3
  50. package/lib/core/tsconfigPaths.d.ts +55 -3
  51. package/lib/core/tsconfigPaths.js +260 -46
  52. package/lib/core/tsconfigPaths.js.map +1 -1
  53. package/lib/core/tsconfigPaths.mjs +255 -47
  54. package/lib/core/tsconfigPaths.mjs.map +1 -1
  55. package/lib/core/viteServe.d.cts +18 -21
  56. package/lib/core/viteServe.d.mts +18 -21
  57. package/lib/core/viteServe.d.ts +18 -21
  58. package/lib/core/viteServe.js +88 -47
  59. package/lib/core/viteServe.js.map +1 -1
  60. package/lib/core/viteServe.mjs +89 -49
  61. package/lib/core/viteServe.mjs.map +1 -1
  62. package/lib/next.d.cts +7 -0
  63. package/lib/next.d.mts +7 -0
  64. package/lib/next.d.ts +7 -0
  65. package/lib/next.js +186 -54
  66. package/lib/next.js.map +1 -1
  67. package/lib/next.mjs +186 -55
  68. package/lib/next.mjs.map +1 -1
  69. package/lib/turbopack.d.cts +6 -4
  70. package/lib/turbopack.d.mts +6 -4
  71. package/lib/turbopack.d.ts +6 -4
  72. package/lib/turbopack.js +11 -10
  73. package/lib/turbopack.js.map +1 -1
  74. package/lib/turbopack.mjs +11 -10
  75. package/lib/turbopack.mjs.map +1 -1
  76. package/package.json +7 -4
  77. package/src/bun-register.ts +112 -25
  78. package/src/bun.ts +60 -50
  79. package/src/core/index.ts +114 -19
  80. package/src/core/projectDiscovery.ts +256 -0
  81. package/src/core/sourceExtensions.ts +47 -0
  82. package/src/core/transform.ts +1671 -361
  83. package/src/core/tsconfigPaths.ts +406 -46
  84. package/src/core/viteServe.ts +125 -74
  85. package/src/next.ts +230 -54
  86. package/src/turbopack.ts +11 -10
@@ -12,19 +12,32 @@ import {
12
12
  type FilesystemPathIdentityContext,
13
13
  type FilesystemPathIdentityOperations,
14
14
  createFilesystemPathIdentityContext,
15
+ resolveFilesystemPath,
15
16
  } from "ttsc/path-identity";
16
17
  import type { TransformResult } from "unplugin";
17
18
 
18
19
  import type { ResolvedTtscUnpluginOptions } from "./options";
19
20
  import {
21
+ findNearestProjectTsconfig,
22
+ isIgnoredProjectDirectory,
23
+ } from "./projectDiscovery";
24
+ import {
25
+ CONFIG_DIR_TEMPLATE_LIST_OPTIONS,
26
+ CONFIG_DIR_TEMPLATE_SCALAR_OPTIONS,
20
27
  type ITtscProjectMembershipPolicy,
21
28
  PERMISSIVE_PROJECT_MEMBERSHIP_POLICY,
22
29
  absolutizePathsTarget,
23
30
  mergeMembershipPolicyOverlay,
24
31
  readEffectiveTsconfigPaths,
32
+ readEffectiveTsconfigTemplateCompilerOptions,
33
+ readEffectiveTsconfigTemplateFileSpecs,
25
34
  readProjectMembershipPolicy,
35
+ readTsconfigSourceSnapshot,
36
+ resolveConfigDirTemplatePath,
26
37
  } from "./tsconfigPaths";
27
38
 
39
+ const TTSC_SEMANTIC_CONFIG_PATH = "TTSC_SEMANTIC_CONFIG_PATH";
40
+
28
41
  /**
29
42
  * The normalised transform result type that this module produces.
30
43
  *
@@ -284,6 +297,12 @@ const TRANSFORM_FAILED_GENERATION_VALIDATIONS = new WeakMap<
284
297
  TtscFailedGenerationValidation
285
298
  >();
286
299
 
300
+ /** Internal retained clock-probe ownership for published generations. */
301
+ const TRANSFORM_CLOCK_REFERENCE_DIRECTORIES = new WeakMap<
302
+ TtscCachedProjectTransform,
303
+ string
304
+ >();
305
+
287
306
  /** Cache promises whose unchanged terminal verdict may be replayed. */
288
307
  const TERMINAL_TRANSFORM_GENERATIONS = new WeakMap<
289
308
  Promise<TtscCachedProjectTransform>,
@@ -310,6 +329,11 @@ const TRANSFORM_GENERATION_ATTEMPTS = 2;
310
329
  * graph-free envelopes retain complete-snapshot validation.
311
330
  */
312
331
  export interface TtscCachedProjectTransform {
332
+ /** Predicate-preserving compiler proofs for external candidate spellings. */
333
+ externalInputObservations?: Record<
334
+ string,
335
+ ITtscCompilerTransformation.IInputObservation
336
+ >;
313
337
  /**
314
338
  * SHA-256 hash of every input the compiler reported outside the project walk
315
339
  * (keyed by filesystem identity), captured at the time of the transform.
@@ -429,6 +453,8 @@ export interface TtscCachedProjectTransform {
429
453
  hostInputValidation?: TtscHostInputValidation;
430
454
  /** Live notification state for file/directory creation, deletion, and rename. */
431
455
  projectMutationTracker?: TtscProjectMutationTracker;
456
+ /** Whether a generated wrapper and its source config graph stayed coherent. */
457
+ configStateComplete?: boolean;
432
458
  /**
433
459
  * Whether the generation-time project walk observed every directory and file
434
460
  * it attempted to snapshot. An incomplete walk may never authorize narrow
@@ -697,40 +723,97 @@ function clearTtscTransformCache(cache: TtscTransformCache): void {
697
723
  * What the generation already knows about one derived watch input, handed to
698
724
  * the adapter so it does not rederive it per input per delivery.
699
725
  *
700
- * Both facts are generation state: the identity is the memoized
701
- * {@link pathIdentityKey} of the input, and `missing` is the existence the
702
- * generation recorded and every cache hit revalidates. An adapter that computes
703
- * them itself pays a `realpath`, a case-sensitivity directory listing, and an
704
- * `existsSync` for every input of every delivered module, which is O(modules x
705
- * inputs) for one build (samchon/ttsc#1246).
726
+ * All facts are generation state: the identity is the memoized
727
+ * {@link pathIdentityKey} of the input, `missing` preserves the original public
728
+ * existence contract, and `unavailable` distinguishes a failed file predicate
729
+ * from ordinary absence. An adapter that computes them itself pays a
730
+ * `realpath`, a case-sensitivity directory listing, and an `existsSync` for
731
+ * every input of every delivered module, which is O(modules x inputs) for one
732
+ * build (samchon/ttsc#1246).
706
733
  */
707
734
  export interface TtscWatchInputEvidence {
708
735
  /** Memoized filesystem identity of the input. */
709
736
  identity: string;
710
- /** Whether the generation recorded this input as absent. */
737
+ /** Whether the generation recorded this input as unavailable as a file. */
711
738
  missing: boolean;
739
+ /** The generation state Metro can compare with its main-process baseline. */
740
+ state?: TtscWatchInputState;
741
+ /** Which unavailable predicate must become true before invalidation. */
742
+ unavailable?: "missing" | "not-file";
743
+ }
744
+
745
+ /** Exact generation state behind one derived watch input. */
746
+ export type TtscWatchInputState =
747
+ | {
748
+ /** A project-walk or dependency-only input read as ordinary host bytes. */
749
+ codec: "host";
750
+ hash: string;
751
+ }
752
+ | {
753
+ /** A realized compiler-graph input, including its physical target. */
754
+ codec: "graph";
755
+ hash: string;
756
+ realpath: string | null;
757
+ }
758
+ | {
759
+ /** The exact compiler predicates observed for a resolver input. */
760
+ codec: "predicates";
761
+ observation: ITtscCompilerTransformation.IInputObservation;
762
+ };
763
+
764
+ /** Main-process file predicate used only by project discovery. */
765
+ export interface TtscWatchInputFileBaseline {
766
+ fileExists: boolean;
767
+ identity: string;
768
+ }
769
+
770
+ /** Main-process state broad enough to compare every watch-input codec. */
771
+ export interface TtscWatchInputBaseline extends TtscWatchInputFileBaseline {
772
+ directoryExists: boolean;
773
+ graphHash: string;
774
+ graphReadHash: string | null;
775
+ hostHash: string;
776
+ realpath: { ok: false; path?: never } | { ok: true; path: string };
777
+ stat: "directory" | "file" | "missing";
778
+ }
779
+
780
+ /** Baseline shape stored for either a discovery predicate or a full input. */
781
+ export type TtscWatchInputKeyBaseline =
782
+ | TtscWatchInputFileBaseline
783
+ | TtscWatchInputBaseline;
784
+
785
+ /** One derived input and its optional generation proof. */
786
+ export interface TtscWatchInput {
787
+ evidence?: TtscWatchInputEvidence;
788
+ file: string;
712
789
  }
713
790
 
714
791
  /**
715
792
  * Hooks the bundler adapter passes into {@link transformTtsc} so transform
716
- * side-channels (plugin-reported dependencies and host resolution candidates)
717
- * reach the bundler without leaking extra fields on the returned
718
- * `TransformResult`.
793
+ * side-channels (plugin-reported dependencies and host resolver inputs) reach
794
+ * the bundler without leaking extra fields on the returned `TransformResult`.
719
795
  */
720
796
  export interface TtscTransformHooks {
721
797
  /**
722
798
  * Invoked once per absolute watch-input path derived for the transformed file
723
799
  * `F`: the plugin-reported `dependencies[F]` list unioned with the host-owned
724
800
  * reference graph's contribution — the reachability closure of `graph.edges`
725
- * from `F`, the `graph.globals` files, the `graph.configs` chain, and missing
726
- * higher-priority `graph.candidates` or, for a file the envelope declared
727
- * `dependenciesComplete`, only `dependencies[F]`, `graph.candidates`, and the
728
- * universal `graph.configs` chain. Adapters forward this to the bundler's
801
+ * from `F`, the `graph.globals` files, the `graph.configs` chain, importer
802
+ * `graph.candidates`, and universal `graph.resolutionInputs`. For a file the
803
+ * envelope declared `dependenciesComplete`, only `dependencies[F]`,
804
+ * `graph.candidates`, `graph.resolutionInputs`, and the universal
805
+ * `graph.configs` chain remain. Adapters forward this to the bundler's
729
806
  * `addWatchFile` so type-only inputs participate in watch-mode and
730
807
  * persistent-cache invalidation. See {@link selectWatchInputs} for the exact
731
808
  * derivation.
732
809
  */
733
810
  addWatchFile?: (file: string, evidence?: TtscWatchInputEvidence) => void;
811
+ /**
812
+ * Batched form of {@link addWatchFile}. When supplied, the transform calls it
813
+ * once per delivered module and does not call `addWatchFile` for that
814
+ * module.
815
+ */
816
+ addWatchFiles?: (inputs: readonly TtscWatchInput[]) => void;
734
817
  /**
735
818
  * Invoked when the plugin declared the transformed file volatile (the
736
819
  * envelope's `volatile` list): its output depends on non-file inputs that no
@@ -756,8 +839,7 @@ export interface TtscTransformHooks {
756
839
  * @param id - Bundler module id (may carry a query string or virtual prefix).
757
840
  * @param source - Current file content supplied by the bundler.
758
841
  * @param options - Resolved plugin options.
759
- * @param aliases - Raw bundler alias configuration (Vite array or webpack
760
- * object).
842
+ * @param aliases - Raw Vite alias configuration (object or array).
761
843
  * @param cache - Optional project cache. Callers with a real `buildStart`
762
844
  * boundary declare it through {@link beginTtscTransformBuild}; other hosts
763
845
  * retain persistent validation.
@@ -868,9 +950,11 @@ export async function transformTtsc(
868
950
  }
869
951
  // The compile is fine and simply has nothing for this module, so the
870
952
  // module goes back to the host untransformed rather than failing the
871
- // build (samchon/ttsc#1308). It still counts as delivered in this
872
- // pass, and there is nothing to watch for a file with no output.
953
+ // build (samchon/ttsc#1308). Its project config and selection inputs
954
+ // still decide whether a later generation will contain this module,
955
+ // so hosts must receive the same universal watch-input batch.
873
956
  reportMissingProgramOutput(cached, error, epoch);
957
+ notifyWatchInputs(hooks, cached, file);
874
958
  markCachedSourceServed(cached, file);
875
959
  return undefined;
876
960
  }
@@ -926,6 +1010,7 @@ export async function transformTtsc(
926
1010
  throw error;
927
1011
  }
928
1012
  reportMissingProgramOutput(cached, error, epoch);
1013
+ notifyWatchInputs(hooks, cached, file);
929
1014
  markCachedSourceServed(cached, file);
930
1015
  return undefined;
931
1016
  }
@@ -1129,7 +1214,7 @@ function evictGeneration(
1129
1214
  }
1130
1215
  }
1131
1216
 
1132
- /** Close one generation's directory watchers exactly once. */
1217
+ /** Release one generation's watchers and retained clock probe exactly once. */
1133
1218
  function disposeCachedTransform(cached: TtscCachedProjectTransform): void {
1134
1219
  const trackers = [
1135
1220
  cached.projectMutationTracker,
@@ -1139,7 +1224,21 @@ function disposeCachedTransform(cached: TtscCachedProjectTransform): void {
1139
1224
  cached.projectMutationTracker = undefined;
1140
1225
  cached.hostInputMutationTracker = undefined;
1141
1226
  cached.candidateMutationTracker = undefined;
1142
- for (const tracker of trackers) tracker?.close();
1227
+ const clockReferenceDirectory =
1228
+ TRANSFORM_CLOCK_REFERENCE_DIRECTORIES.get(cached);
1229
+ TRANSFORM_CLOCK_REFERENCE_DIRECTORIES.delete(cached);
1230
+ for (const tracker of trackers) {
1231
+ try {
1232
+ tracker?.close();
1233
+ } catch {
1234
+ // Disposal is scheduled behind fulfilled generation Promises, so it has
1235
+ // no caller that can recover from a close failure. Keep releasing every
1236
+ // independent resource and leave no rejected cleanup Promise behind.
1237
+ }
1238
+ }
1239
+ if (clockReferenceDirectory !== undefined) {
1240
+ disposeFilesystemClockReference(clockReferenceDirectory);
1241
+ }
1143
1242
  }
1144
1243
 
1145
1244
  /**
@@ -1198,7 +1297,7 @@ interface TtscEnvelopeDerivation {
1198
1297
  * mirroring the historical scan). `undefined` until the first key miss.
1199
1298
  */
1200
1299
  dependencyIndex?: Map<string, unknown>;
1201
- /** Per-file memo of the final derived watch-input list. */
1300
+ /** Per lexical delivered-module spelling memo of its final watch-input list. */
1202
1301
  readonly watchInputs: Map<string, string[]>;
1203
1302
  /**
1204
1303
  * Lazily built project-walk keys of the envelope's declared inputs, and
@@ -1255,38 +1354,39 @@ interface TtscEnvelopeGraphIndexes {
1255
1354
  readonly edges: Map<string, string[]>;
1256
1355
  /** Identity of each direct-edge source -> its absolute spelling. */
1257
1356
  readonly spellings: Map<string, string>;
1258
- /** Resolution-candidate entries in envelope order, sources pre-identified. */
1357
+ /** Importer-owned resolver-input entries, sources pre-identified. */
1259
1358
  readonly candidates: { source: string; files: string[] }[];
1260
1359
  /** Resolved absolute `graph.globals` and `graph.configs` members. */
1261
1360
  readonly globals: string[];
1262
1361
  readonly configs: string[];
1263
- /** Every realized/candidate graph path, keyed by filesystem identity. */
1264
- readonly members: Set<string>;
1362
+ /** Resolver inputs whose state affects every source file. */
1363
+ readonly resolutionInputs: string[];
1364
+ /** Every realized or resolver-input path, keyed by lexical spelling. */
1365
+ readonly memberSpellings: Set<string>;
1265
1366
  /**
1266
- * Members the envelope reported only under `graph.candidates`, keyed by
1267
- * filesystem identity.
1268
- *
1269
- * A superseding candidate is by construction a path the compiler did not
1270
- * select, and usually one it never read at all: resolution stopped at the
1271
- * target that won, and the host enumerates the higher-priority spellings so
1272
- * that one appearing later can invalidate the generation. Such a path has no
1273
- * compile-time read to prove, so it carries the evidence a plugin-declared
1274
- * dependency path carries (the state recorded when the envelope was produced)
1275
- * instead of a compiler proof it can never have.
1276
- *
1277
- * A candidate that is also a realized input (an edge endpoint, a global, or a
1278
- * config) is absent from this set and keeps the realized standard.
1367
+ * Predicate-only resolver inputs, keyed by absolute lexical spelling. These
1368
+ * are exact compiler calls but need not carry file content, for example a
1369
+ * failed file predicate or automatic type-root directory enumeration. A path
1370
+ * that is also a realized edge, global, config, or source keeps the stronger
1371
+ * realized-file standard.
1279
1372
  */
1280
1373
  readonly speculative: Set<string>;
1281
- /** Compiler-time proof for graph members, keyed by filesystem identity. */
1374
+ /** Compiler-time legacy proof keyed by absolute lexical spelling. */
1282
1375
  readonly inputProofs: Map<
1283
1376
  string,
1284
1377
  { hash: string | null; path: string; realpath: string | null }
1285
1378
  >;
1286
- /** Native compiler-observation failure for an unproven member identity. */
1379
+ /** Native compiler-observation failure keyed by absolute lexical spelling. */
1287
1380
  readonly inputProofFailures: Map<string, string>;
1288
- /** Aliased graph proof keys that reported contradictory generation states. */
1381
+ /** Graph proof spellings that reported contradictory generation states. */
1289
1382
  readonly inputProofConflicts: Set<string>;
1383
+ /** Predicate-preserving proofs keyed by absolute lexical spelling. */
1384
+ readonly inputObservations: Map<
1385
+ string,
1386
+ ITtscCompilerTransformation.IInputObservation
1387
+ >;
1388
+ /** Absolute spellings whose predicate proof is malformed or contradictory. */
1389
+ readonly inputObservationConflicts: Set<string>;
1290
1390
  }
1291
1391
 
1292
1392
  /**
@@ -1334,17 +1434,22 @@ function envelopeGraphIndexes(
1334
1434
  if (state.graph !== undefined) {
1335
1435
  return state.graph;
1336
1436
  }
1437
+ const platform = resultFilesystem(props.result).platform ?? process.platform;
1438
+ const pathApi = platform === "win32" ? path.win32 : path.posix;
1337
1439
  const built: TtscEnvelopeGraphIndexes = {
1338
1440
  edges: new Map(),
1339
1441
  spellings: new Map(),
1340
1442
  candidates: [],
1341
1443
  globals: [],
1342
1444
  configs: [],
1343
- members: new Set(),
1445
+ resolutionInputs: [],
1446
+ memberSpellings: new Set(),
1344
1447
  speculative: new Set(),
1345
1448
  inputProofs: new Map(),
1346
1449
  inputProofFailures: new Map(),
1347
1450
  inputProofConflicts: new Set(),
1451
+ inputObservations: new Map(),
1452
+ inputObservationConflicts: new Set(),
1348
1453
  };
1349
1454
  const graph =
1350
1455
  props.result.type === "exception" ? undefined : props.result.graph;
@@ -1355,7 +1460,7 @@ function envelopeGraphIndexes(
1355
1460
  }
1356
1461
  const absolute = path.resolve(props.projectRoot, source);
1357
1462
  const identity = derivationIdentity(state, absolute);
1358
- built.members.add(identity);
1463
+ built.memberSpellings.add(absolute);
1359
1464
  built.spellings.set(identity, absolute);
1360
1465
  const entries = built.edges.get(identity) ?? [];
1361
1466
  entries.push(
@@ -1367,7 +1472,7 @@ function envelopeGraphIndexes(
1367
1472
  .map((target) => {
1368
1473
  const absoluteTarget = path.resolve(props.projectRoot, target);
1369
1474
  const targetIdentity = derivationIdentity(state, absoluteTarget);
1370
- built.members.add(targetIdentity);
1475
+ built.memberSpellings.add(absoluteTarget);
1371
1476
  if (!built.spellings.has(targetIdentity)) {
1372
1477
  built.spellings.set(targetIdentity, absoluteTarget);
1373
1478
  }
@@ -1380,7 +1485,7 @@ function envelopeGraphIndexes(
1380
1485
  built.configs.push(...selectListedFiles(props.projectRoot, graph.configs));
1381
1486
  for (const input of [...built.globals, ...built.configs]) {
1382
1487
  const identity = derivationIdentity(state, input);
1383
- built.members.add(identity);
1488
+ built.memberSpellings.add(path.resolve(input));
1384
1489
  if (!built.spellings.has(identity)) built.spellings.set(identity, input);
1385
1490
  }
1386
1491
  const candidateEntries = Object.entries(graph.candidates ?? {}).filter(
@@ -1393,12 +1498,22 @@ function envelopeGraphIndexes(
1393
1498
  for (const [source] of candidateEntries) {
1394
1499
  const absoluteSource = path.resolve(props.projectRoot, source);
1395
1500
  const identity = derivationIdentity(state, absoluteSource);
1396
- built.members.add(identity);
1501
+ built.memberSpellings.add(absoluteSource);
1397
1502
  if (!built.spellings.has(identity)) {
1398
1503
  built.spellings.set(identity, absoluteSource);
1399
1504
  }
1400
1505
  }
1401
- const realized = new Set(built.members);
1506
+ const realized = new Set(built.memberSpellings);
1507
+ built.resolutionInputs.push(
1508
+ ...selectListedFiles(props.projectRoot, graph.resolutionInputs),
1509
+ );
1510
+ for (const input of built.resolutionInputs) {
1511
+ const spelling = path.resolve(input);
1512
+ const identity = derivationIdentity(state, input);
1513
+ if (!realized.has(spelling)) built.speculative.add(spelling);
1514
+ built.memberSpellings.add(spelling);
1515
+ if (!built.spellings.has(identity)) built.spellings.set(identity, input);
1516
+ }
1402
1517
  for (const [source, candidates] of candidateEntries) {
1403
1518
  built.candidates.push({
1404
1519
  source: derivationIdentity(
@@ -1409,33 +1524,64 @@ function envelopeGraphIndexes(
1409
1524
  });
1410
1525
  for (const candidate of candidates) {
1411
1526
  if (typeof candidate !== "string" || candidate.length === 0) continue;
1412
- const identity = derivationIdentity(
1413
- state,
1414
- path.resolve(props.projectRoot, candidate),
1415
- );
1527
+ const absoluteCandidate = path.resolve(props.projectRoot, candidate);
1528
+ const identity = derivationIdentity(state, absoluteCandidate);
1416
1529
  // Edges, globals, configs, and every candidate source are folded in
1417
1530
  // above, so a path absent from that set is one the envelope reported
1418
1531
  // only as a candidate.
1419
- if (!realized.has(identity)) built.speculative.add(identity);
1420
- built.members.add(identity);
1532
+ if (!realized.has(absoluteCandidate)) {
1533
+ built.speculative.add(absoluteCandidate);
1534
+ }
1535
+ built.memberSpellings.add(absoluteCandidate);
1421
1536
  if (!built.spellings.has(identity)) {
1422
- built.spellings.set(
1423
- identity,
1424
- path.resolve(props.projectRoot, candidate),
1425
- );
1537
+ built.spellings.set(identity, absoluteCandidate);
1426
1538
  }
1427
1539
  }
1428
1540
  }
1429
- const transformSources = new Set<string>();
1541
+ const transformSourceSpellings = new Set<string>();
1430
1542
  if (props.result.type === "success") {
1431
1543
  for (const output of Object.keys(props.result.typescript)) {
1432
1544
  if (!isDeclarationFile(output)) {
1433
- transformSources.add(
1434
- derivationIdentity(state, path.resolve(props.projectRoot, output)),
1435
- );
1545
+ const absoluteOutput = path.resolve(props.projectRoot, output);
1546
+ transformSourceSpellings.add(absoluteOutput);
1436
1547
  }
1437
1548
  }
1438
1549
  }
1550
+ for (const [input, reported] of Object.entries(
1551
+ graph.inputObservations ?? {},
1552
+ )) {
1553
+ if (input.length === 0) continue;
1554
+ const absolute = path.resolve(props.projectRoot, input);
1555
+ const spelling = path.resolve(absolute);
1556
+ if (
1557
+ !built.memberSpellings.has(spelling) &&
1558
+ !transformSourceSpellings.has(spelling)
1559
+ ) {
1560
+ continue;
1561
+ }
1562
+ const normalized = normalizeGraphInputObservation(reported, platform);
1563
+ if (normalized === undefined) {
1564
+ built.inputObservationConflicts.add(spelling);
1565
+ if (!built.inputProofFailures.has(spelling)) {
1566
+ built.inputProofFailures.set(spelling, "malformed-observation");
1567
+ }
1568
+ continue;
1569
+ }
1570
+ const previous = built.inputObservations.get(spelling);
1571
+ const merged =
1572
+ previous === undefined
1573
+ ? normalized
1574
+ : mergeGraphInputObservations(previous, normalized);
1575
+ if (merged === undefined) {
1576
+ built.inputObservations.delete(spelling);
1577
+ built.inputObservationConflicts.add(spelling);
1578
+ if (!built.inputProofFailures.has(spelling)) {
1579
+ built.inputProofFailures.set(spelling, "conflicting-observation");
1580
+ }
1581
+ } else if (!built.inputObservationConflicts.has(spelling)) {
1582
+ built.inputObservations.set(spelling, merged);
1583
+ }
1584
+ }
1439
1585
  for (const [input, hash] of Object.entries(graph.inputHashes ?? {})) {
1440
1586
  if (
1441
1587
  hash !== null &&
@@ -1453,22 +1599,27 @@ function envelopeGraphIndexes(
1453
1599
  if (
1454
1600
  reportedRealpath !== null &&
1455
1601
  (typeof reportedRealpath !== "string" ||
1456
- !path.isAbsolute(reportedRealpath))
1602
+ !pathApi.isAbsolute(reportedRealpath))
1457
1603
  ) {
1458
1604
  continue;
1459
1605
  }
1460
1606
  const absolute = path.resolve(props.projectRoot, input);
1461
- const identity = derivationIdentity(state, absolute);
1462
- if (!built.members.has(identity) && !transformSources.has(identity)) {
1607
+ const spelling = path.resolve(absolute);
1608
+ if (
1609
+ !built.memberSpellings.has(spelling) &&
1610
+ !transformSourceSpellings.has(spelling)
1611
+ ) {
1463
1612
  continue;
1464
1613
  }
1465
1614
  const proof = {
1466
1615
  hash,
1467
1616
  path: absolute,
1468
1617
  realpath:
1469
- reportedRealpath === null ? null : path.resolve(reportedRealpath),
1618
+ reportedRealpath === null
1619
+ ? null
1620
+ : resolveFilesystemPath(reportedRealpath, platform),
1470
1621
  };
1471
- const previous = built.inputProofs.get(identity);
1622
+ const previous = built.inputProofs.get(spelling);
1472
1623
  if (
1473
1624
  previous !== undefined &&
1474
1625
  (previous.hash !== proof.hash ||
@@ -1478,10 +1629,10 @@ function envelopeGraphIndexes(
1478
1629
  state.identityContext,
1479
1630
  ))
1480
1631
  ) {
1481
- built.inputProofs.delete(identity);
1482
- built.inputProofConflicts.add(identity);
1483
- } else if (!built.inputProofConflicts.has(identity)) {
1484
- built.inputProofs.set(identity, proof);
1632
+ built.inputProofs.delete(spelling);
1633
+ built.inputProofConflicts.add(spelling);
1634
+ } else if (!built.inputProofConflicts.has(spelling)) {
1635
+ built.inputProofs.set(spelling, proof);
1485
1636
  }
1486
1637
  }
1487
1638
  for (const [input, reason] of Object.entries(
@@ -1491,16 +1642,35 @@ function envelopeGraphIndexes(
1491
1642
  continue;
1492
1643
  }
1493
1644
  const absolute = path.resolve(props.projectRoot, input);
1494
- const identity = derivationIdentity(state, absolute);
1495
- if (!built.members.has(identity) && !transformSources.has(identity)) {
1645
+ const spelling = path.resolve(absolute);
1646
+ if (
1647
+ !built.memberSpellings.has(spelling) &&
1648
+ !transformSourceSpellings.has(spelling)
1649
+ ) {
1650
+ continue;
1651
+ }
1652
+ const observation = built.inputObservations.get(spelling);
1653
+ const legacyProjection =
1654
+ observation === undefined
1655
+ ? undefined
1656
+ : legacyProjectionOfGraphInputObservation(observation);
1657
+ if (
1658
+ built.speculative.has(spelling) &&
1659
+ !built.inputProofs.has(spelling) &&
1660
+ legacyProjection?.failure === reason
1661
+ ) {
1496
1662
  continue;
1497
1663
  }
1498
- if (built.inputProofs.has(identity)) {
1499
- built.inputProofs.delete(identity);
1500
- built.inputProofConflicts.add(identity);
1664
+ if (built.inputObservations.has(spelling)) {
1665
+ built.inputObservations.delete(spelling);
1666
+ built.inputObservationConflicts.add(spelling);
1501
1667
  }
1502
- if (!built.inputProofFailures.has(identity)) {
1503
- built.inputProofFailures.set(identity, reason);
1668
+ if (built.inputProofs.has(spelling)) {
1669
+ built.inputProofs.delete(spelling);
1670
+ built.inputProofConflicts.add(spelling);
1671
+ }
1672
+ if (!built.inputProofFailures.has(spelling)) {
1673
+ built.inputProofFailures.set(spelling, reason);
1504
1674
  }
1505
1675
  }
1506
1676
  }
@@ -1508,6 +1678,218 @@ function envelopeGraphIndexes(
1508
1678
  return built;
1509
1679
  }
1510
1680
 
1681
+ /** Normalize one untrusted predicate proof without filling missing predicates. */
1682
+ function normalizeGraphInputObservation(
1683
+ value: unknown,
1684
+ platform: NodeJS.Platform = process.platform,
1685
+ ): ITtscCompilerTransformation.IInputObservation | undefined {
1686
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1687
+ return undefined;
1688
+ }
1689
+ const entry = value as Record<string, unknown>;
1690
+ const observation: ITtscCompilerTransformation.IInputObservation = {};
1691
+ if (Object.prototype.hasOwnProperty.call(entry, "accessibleEntries")) {
1692
+ const accessible = entry.accessibleEntries;
1693
+ if (
1694
+ typeof accessible !== "object" ||
1695
+ accessible === null ||
1696
+ Array.isArray(accessible)
1697
+ ) {
1698
+ return undefined;
1699
+ }
1700
+ const lists = accessible as Record<string, unknown>;
1701
+ if (
1702
+ !Array.isArray(lists.directories) ||
1703
+ !lists.directories.every(
1704
+ (name): name is string => typeof name === "string",
1705
+ ) ||
1706
+ !Array.isArray(lists.files) ||
1707
+ !lists.files.every((name): name is string => typeof name === "string")
1708
+ ) {
1709
+ return undefined;
1710
+ }
1711
+ observation.accessibleEntries = {
1712
+ directories: [...lists.directories],
1713
+ files: [...lists.files],
1714
+ };
1715
+ }
1716
+ if (Object.prototype.hasOwnProperty.call(entry, "fileExists")) {
1717
+ if (typeof entry.fileExists !== "boolean") return undefined;
1718
+ observation.fileExists = entry.fileExists;
1719
+ }
1720
+ if (Object.prototype.hasOwnProperty.call(entry, "directoryExists")) {
1721
+ if (typeof entry.directoryExists !== "boolean") return undefined;
1722
+ observation.directoryExists = entry.directoryExists;
1723
+ }
1724
+ if (Object.prototype.hasOwnProperty.call(entry, "stat")) {
1725
+ if (
1726
+ entry.stat !== "missing" &&
1727
+ entry.stat !== "file" &&
1728
+ entry.stat !== "directory"
1729
+ ) {
1730
+ return undefined;
1731
+ }
1732
+ observation.stat = entry.stat;
1733
+ }
1734
+ if (Object.prototype.hasOwnProperty.call(entry, "readFile")) {
1735
+ const read = entry.readFile;
1736
+ if (typeof read !== "object" || read === null || Array.isArray(read)) {
1737
+ return undefined;
1738
+ }
1739
+ const result = read as Record<string, unknown>;
1740
+ if (result.ok === false && result.hash === undefined) {
1741
+ observation.readFile = { ok: false };
1742
+ } else if (
1743
+ result.ok === true &&
1744
+ typeof result.hash === "string" &&
1745
+ /^[0-9a-f]{64}$/.test(result.hash)
1746
+ ) {
1747
+ observation.readFile = { hash: result.hash, ok: true };
1748
+ } else {
1749
+ return undefined;
1750
+ }
1751
+ }
1752
+ if (Object.prototype.hasOwnProperty.call(entry, "realpath")) {
1753
+ const realpath = entry.realpath;
1754
+ if (
1755
+ typeof realpath !== "object" ||
1756
+ realpath === null ||
1757
+ Array.isArray(realpath)
1758
+ ) {
1759
+ return undefined;
1760
+ }
1761
+ const result = realpath as Record<string, unknown>;
1762
+ if (result.ok === false && result.path === undefined) {
1763
+ observation.realpath = { ok: false };
1764
+ } else if (
1765
+ result.ok === true &&
1766
+ typeof result.path === "string" &&
1767
+ (platform === "win32" ? path.win32 : path.posix).isAbsolute(result.path)
1768
+ ) {
1769
+ observation.realpath = {
1770
+ ok: true,
1771
+ path: resolveFilesystemPath(result.path, platform),
1772
+ };
1773
+ } else {
1774
+ return undefined;
1775
+ }
1776
+ }
1777
+ return Object.keys(observation).length !== 0 &&
1778
+ graphInputObservationCompatible(observation)
1779
+ ? observation
1780
+ : undefined;
1781
+ }
1782
+
1783
+ /** Join duplicate lexical keys only when every repeated predicate agrees. */
1784
+ function mergeGraphInputObservations(
1785
+ left: ITtscCompilerTransformation.IInputObservation,
1786
+ right: ITtscCompilerTransformation.IInputObservation,
1787
+ ): ITtscCompilerTransformation.IInputObservation | undefined {
1788
+ for (const property of [
1789
+ "accessibleEntries",
1790
+ "directoryExists",
1791
+ "fileExists",
1792
+ "readFile",
1793
+ "realpath",
1794
+ "stat",
1795
+ ] as const) {
1796
+ if (
1797
+ left[property] !== undefined &&
1798
+ right[property] !== undefined &&
1799
+ JSON.stringify(left[property]) !== JSON.stringify(right[property])
1800
+ ) {
1801
+ return undefined;
1802
+ }
1803
+ }
1804
+ const merged = { ...left, ...right };
1805
+ return graphInputObservationCompatible(merged) ? merged : undefined;
1806
+ }
1807
+
1808
+ /** Whether one predicate set can describe a single stable filesystem state. */
1809
+ function graphInputObservationCompatible(
1810
+ observation: ITtscCompilerTransformation.IInputObservation,
1811
+ ): boolean {
1812
+ const { accessibleEntries, directoryExists, fileExists, readFile, stat } =
1813
+ observation;
1814
+ const hasAccessibleEntries =
1815
+ accessibleEntries !== undefined &&
1816
+ (accessibleEntries.directories.length !== 0 ||
1817
+ accessibleEntries.files.length !== 0);
1818
+ if (
1819
+ hasAccessibleEntries &&
1820
+ (fileExists === true ||
1821
+ directoryExists === false ||
1822
+ (stat !== undefined && stat !== "directory") ||
1823
+ readFile?.ok === true)
1824
+ ) {
1825
+ return false;
1826
+ }
1827
+ if (fileExists === true && directoryExists === true) return false;
1828
+ if (
1829
+ stat === "directory" &&
1830
+ (fileExists === true || directoryExists === false)
1831
+ ) {
1832
+ return false;
1833
+ }
1834
+ if (stat === "file" && (fileExists === false || directoryExists === true)) {
1835
+ return false;
1836
+ }
1837
+ if (stat === "missing" && (fileExists === true || directoryExists === true)) {
1838
+ return false;
1839
+ }
1840
+ if (
1841
+ readFile?.ok === true &&
1842
+ (fileExists === false ||
1843
+ directoryExists === true ||
1844
+ (stat !== undefined && stat !== "file"))
1845
+ ) {
1846
+ return false;
1847
+ }
1848
+ return true;
1849
+ }
1850
+
1851
+ /**
1852
+ * Reconstruct a legacy hash/realpath pair only when the rich predicates prove
1853
+ * that projection without collapsing an unreadable file or failed file probe
1854
+ * into generic absence.
1855
+ */
1856
+ function legacyProjectionOfGraphInputObservation(
1857
+ observation: ITtscCompilerTransformation.IInputObservation,
1858
+ ):
1859
+ | { failure?: undefined; hash: string | null; realpath: string | null }
1860
+ | { failure: string } {
1861
+ if (observation.readFile?.ok === true) {
1862
+ return observation.realpath?.ok === true
1863
+ ? {
1864
+ hash: observation.readFile.hash,
1865
+ realpath: observation.realpath.path,
1866
+ }
1867
+ : { failure: "realpath-unavailable" };
1868
+ }
1869
+ const directory =
1870
+ observation.stat === "directory" || observation.directoryExists === true;
1871
+ if (directory) {
1872
+ return observation.realpath?.ok === true
1873
+ ? {
1874
+ hash: hashText("ttsc:host-input:directory\0"),
1875
+ realpath: observation.realpath.path,
1876
+ }
1877
+ : { failure: "realpath-unavailable" };
1878
+ }
1879
+ const file = observation.stat === "file" || observation.fileExists === true;
1880
+ if (file) {
1881
+ return { failure: "content-unavailable" };
1882
+ }
1883
+ const missing =
1884
+ observation.stat === "missing" ||
1885
+ observation.fileExists === false ||
1886
+ observation.directoryExists === false ||
1887
+ observation.readFile?.ok === false;
1888
+ return missing
1889
+ ? { hash: null, realpath: null }
1890
+ : { failure: "unsupported-input-kind" };
1891
+ }
1892
+
1511
1893
  /**
1512
1894
  * {@link pathIdentityKey} memoized inside one envelope's derivation state.
1513
1895
  * Callers always pass already-resolved absolute paths, so the input string is a
@@ -1551,21 +1933,8 @@ function collectDeclaredIdentities(
1551
1933
  }
1552
1934
 
1553
1935
  /**
1554
- * Forward every derived watch input for `file` to the adapter's `addWatchFile`
1555
- * hook: the plugin-reported `dependencies[file]` list unioned with the
1556
- * host-owned reference graph's contribution (`reach(edges, file)`, `globals`,
1557
- * `configs`, and resolution candidates).
1558
- *
1559
- * Envelope keys mirror the `typescript` keys (project-relative); values may be
1560
- * project-relative or absolute. Every path is absolutized against the project
1561
- * root and deduplicated; the file itself is dropped (the bundler already
1562
- * watches the module it transforms), and so is every path in the disposed
1563
- * transform scratch tree (see
1564
- * {@link TtscCachedProjectTransform.scratchDirectory}).
1565
- */
1566
- /**
1567
- * Register the failed generation's own project inputs so the host can observe
1568
- * the fix.
1936
+ * Register the failed generation's project and external inputs so the host can
1937
+ * observe the fix.
1569
1938
  *
1570
1939
  * A successful delivery registers the derived watch inputs, which is how a
1571
1940
  * type-only file that no bundler graph contains still invalidates the modules
@@ -1576,76 +1945,200 @@ function collectDeclaredIdentities(
1576
1945
  * arrive: the user repairs a file the bundler does not track, nothing is
1577
1946
  * invalidated, and the error stays on screen (samchon/ttsc#1312).
1578
1947
  *
1579
- * The generation records the project walk even when the compile failed, so the
1580
- * files a fix would touch are exactly what it already holds. The cost is paid
1581
- * only on a failure, and only until the next compile succeeds and narrows the
1582
- * set back to the derived inputs.
1948
+ * A failure envelope can retain exact external input spellings from its graph
1949
+ * and host metadata. A pre-transform typecheck failure may have no graph yet;
1950
+ * its structured diagnostics, or the host's standard diagnostic lines when it
1951
+ * could return only an exception, still name the external files that need a
1952
+ * repair. The cost is paid only on a failure, and only until the next compile
1953
+ * succeeds and narrows the set back to the derived inputs.
1583
1954
  */
1584
1955
  function notifyFailedGenerationInputs(
1585
1956
  hooks: TtscTransformHooks | undefined,
1586
1957
  cached: TtscCachedProjectTransform,
1587
1958
  ): void {
1588
1959
  const addWatchFile = hooks?.addWatchFile;
1589
- if (addWatchFile === undefined) {
1960
+ const addWatchFiles = hooks?.addWatchFiles;
1961
+ if (addWatchFile === undefined && addWatchFiles === undefined) {
1590
1962
  return;
1591
1963
  }
1964
+ const inputs: TtscWatchInput[] = [];
1965
+ const seen = new Set<string>();
1966
+ const append = (input: string): void => {
1967
+ const spelling = path.resolve(input);
1968
+ if (
1969
+ seen.has(spelling) ||
1970
+ isTransformScratchInput(spelling, cached.scratchDirectory)
1971
+ ) {
1972
+ return;
1973
+ }
1974
+ seen.add(spelling);
1975
+ // No evidence, deliberately. A failed generation is replayed for the rest
1976
+ // of its pass without re-proving its inputs, so the adapter must observe
1977
+ // the current availability itself.
1978
+ inputs.push({ file: spelling });
1979
+ };
1592
1980
  for (const key of Object.keys(cached.inputHashes)) {
1593
- const input = path.resolve(cached.projectRoot, key);
1594
- if (isTransformScratchInput(input, cached.scratchDirectory)) {
1595
- continue;
1981
+ append(path.resolve(cached.projectRoot, key));
1982
+ }
1983
+ // The project walk deliberately excludes node_modules and cannot reach
1984
+ // sibling-project or out-of-root inputs. The generation already retained
1985
+ // their exact lexical spellings, so keep that ownership on the failure path
1986
+ // instead of deriving a narrower second answer.
1987
+ for (const input of cached.externalInputPaths ?? []) {
1988
+ append(input);
1989
+ }
1990
+ if (cached.result.type === "failure") {
1991
+ for (const diagnostic of cached.result.diagnostics) {
1992
+ if (typeof diagnostic.file === "string" && diagnostic.file.length !== 0) {
1993
+ append(path.resolve(cached.projectRoot, diagnostic.file));
1994
+ }
1995
+ }
1996
+ } else if (cached.result.type === "exception") {
1997
+ for (const diagnostic of selectExceptionDiagnosticFiles(
1998
+ cached.result.error,
1999
+ )) {
2000
+ append(path.resolve(cached.projectRoot, diagnostic));
1596
2001
  }
1597
- // No evidence argument, deliberately. `missing: false` would be a claim
1598
- // this path cannot back: a failed generation is replayed for the rest of
1599
- // its pass without re-proving its inputs, so the walk that recorded them
1600
- // may be older than the delivery, and one of them having been deleted is a
1601
- // live reason for that compile to have failed. Letting the adapter probe
1602
- // also routes an absent input to the missing-input poll, which is the only
1603
- // channel through which restoring it can invalidate anything: a bundler
1604
- // watch on a path that does not exist registers nothing, and no module
1605
- // graph carries a type-only input. It costs one `existsSync` per input,
1606
- // and only where the adapter reads evidence at all, which is Vite serve.
1607
- addWatchFile(input);
2002
+ }
2003
+ if (addWatchFiles !== undefined) {
2004
+ addWatchFiles(inputs);
2005
+ return;
2006
+ }
2007
+ for (const input of inputs) {
2008
+ addWatchFile!(input.file);
1608
2009
  }
1609
2010
  }
1610
2011
 
2012
+ /**
2013
+ * Extract file spellings only from the two standard TypeScript diagnostic
2014
+ * forms.
2015
+ */
2016
+ function selectExceptionDiagnosticFiles(error: unknown): string[] {
2017
+ const files: string[] = [];
2018
+ for (const line of formatUnknownError(error).split(/\r?\n/)) {
2019
+ const colon =
2020
+ /^(.+):\d+:\d+\s+-\s+(?:error|warning|suggestion|message)\s+TS\d+:\s+.+$/i.exec(
2021
+ line,
2022
+ );
2023
+ const parenthesized =
2024
+ /^(.+?)\(\d+,\d+\):\s+(?:error|warning|suggestion|message)\s+TS\d+:\s+.+$/i.exec(
2025
+ line,
2026
+ );
2027
+ const file = colon?.[1] ?? parenthesized?.[1];
2028
+ if (file !== undefined && file.trim().length !== 0) {
2029
+ files.push(file.trim());
2030
+ }
2031
+ }
2032
+ return files;
2033
+ }
2034
+
2035
+ /**
2036
+ * Forward every derived watch input for `file` to the adapter's `addWatchFile`
2037
+ * hook: the plugin-reported `dependencies[file]` list unioned with the
2038
+ * host-owned reference graph's contribution (`reach(edges, file)`, `globals`,
2039
+ * `configs`, and resolution candidates).
2040
+ *
2041
+ * Envelope keys mirror the `typescript` keys (project-relative); values may be
2042
+ * project-relative or absolute. Every path is absolutized against the project
2043
+ * root and deduplicated; the file itself is dropped (the bundler already
2044
+ * watches the module it transforms), and so is every path in the disposed
2045
+ * transform scratch tree (see
2046
+ * {@link TtscCachedProjectTransform.scratchDirectory}).
2047
+ */
1611
2048
  function notifyWatchInputs(
1612
2049
  hooks: TtscTransformHooks | undefined,
1613
2050
  cached: TtscCachedProjectTransform,
1614
2051
  file: string,
1615
2052
  ): void {
1616
2053
  const addWatchFile = hooks?.addWatchFile;
1617
- if (addWatchFile === undefined) {
2054
+ const addWatchFiles = hooks?.addWatchFiles;
2055
+ if (addWatchFile === undefined && addWatchFiles === undefined) {
1618
2056
  return;
1619
2057
  }
1620
2058
  const state = envelopeDerivation(cached);
1621
2059
  const external = cached.externalInputHashes ?? {};
1622
- for (const input of selectWatchInputs({
2060
+ const inputs = selectWatchInputs({
1623
2061
  file,
1624
2062
  projectRoot: cached.projectRoot,
1625
2063
  result: cached.result,
1626
2064
  scratchDirectory: cached.scratchDirectory,
1627
2065
  temporaryTsconfig: cached.temporaryTsconfig,
1628
- })) {
2066
+ }).map((input): TtscWatchInput => {
1629
2067
  // Hand the adapter the identity this generation already resolved and the
1630
- // existence state it already recorded. Both are memoized per generation,
1631
- // while an adapter deriving them itself pays a `realpath`, a directory
1632
- // listing, and an `existsSync` per input on every delivery of every module
1633
- // (samchon/ttsc#1246).
2068
+ // exact state it already recorded. Both are memoized per generation, while
2069
+ // an adapter deriving them itself pays repeated filesystem reads and can
2070
+ // accidentally attach a later state to an earlier transform.
1634
2071
  const identity = derivationIdentity(state, input);
1635
- addWatchFile(input, {
2072
+ const spelling = path.resolve(input);
2073
+ const observation = cached.externalInputObservations?.[spelling];
2074
+ const missing =
2075
+ observation?.fileExists === false ||
2076
+ state.graph?.inputProofs.get(spelling)?.hash === null ||
2077
+ external[identity] === MISSING_INPUT_STATE;
2078
+ const projectKey = toProjectKey(
2079
+ cached.projectRoot,
2080
+ input,
2081
+ state.identityContext,
2082
+ );
2083
+ const externalHash = Object.prototype.hasOwnProperty.call(
2084
+ external,
1636
2085
  identity,
1637
- missing: external[identity] === MISSING_INPUT_STATE,
1638
- });
2086
+ )
2087
+ ? external[identity]
2088
+ : undefined;
2089
+ const projectHash = Object.prototype.hasOwnProperty.call(
2090
+ cached.inputHashes,
2091
+ projectKey,
2092
+ )
2093
+ ? cached.inputHashes[projectKey]
2094
+ : undefined;
2095
+ const graphRealpaths = cached.externalInputRealpaths ?? {};
2096
+ const stateEvidence: TtscWatchInputState | undefined =
2097
+ observation !== undefined
2098
+ ? { codec: "predicates", observation }
2099
+ : externalHash !== undefined &&
2100
+ Object.prototype.hasOwnProperty.call(graphRealpaths, identity)
2101
+ ? {
2102
+ codec: "graph",
2103
+ hash: externalHash,
2104
+ realpath: graphRealpaths[identity] ?? null,
2105
+ }
2106
+ : externalHash !== undefined
2107
+ ? { codec: "host", hash: externalHash }
2108
+ : projectHash !== undefined
2109
+ ? { codec: "host", hash: projectHash }
2110
+ : undefined;
2111
+ return {
2112
+ file: input,
2113
+ evidence: {
2114
+ identity,
2115
+ missing,
2116
+ ...(stateEvidence === undefined ? {} : { state: stateEvidence }),
2117
+ unavailable:
2118
+ observation?.fileExists === false
2119
+ ? "not-file"
2120
+ : missing
2121
+ ? "missing"
2122
+ : undefined,
2123
+ },
2124
+ };
2125
+ });
2126
+ if (addWatchFiles !== undefined) {
2127
+ addWatchFiles(inputs);
2128
+ return;
2129
+ }
2130
+ for (const input of inputs) {
2131
+ addWatchFile!(input.file, input.evidence);
1639
2132
  }
1640
2133
  }
1641
2134
 
1642
2135
  /**
1643
2136
  * Derive the absolute, deduplicated watch-input list for a single file.
1644
2137
  *
1645
- * By default the derivation is a union: `dependencies[file] reach(edges,
1646
- * file) ∪ globals configs`. The plugin-reported list can only widen the
1647
- * host-owned language-semantic bound, never narrow it. Resolution candidates
1648
- * remain part of that host-owned bound in both modes.
2138
+ * By default the derivation unions plugin dependencies, the reachable graph,
2139
+ * globals, configs, importer resolver inputs, and universal resolver inputs.
2140
+ * The plugin-reported list can only widen the host-owned language-semantic
2141
+ * bound, never narrow it. Resolver inputs remain in that bound in both modes.
1649
2142
  *
1650
2143
  * An envelope that lists `file` in `dependenciesComplete` narrows it to
1651
2144
  * `dependencies[file] ∪ configs`: the plugin declared its reported list the
@@ -1657,10 +2150,12 @@ function notifyWatchInputs(
1657
2150
  * volatile keeps the baseline: the two declarations contradict, so the
1658
2151
  * conservative one wins.
1659
2152
  *
1660
- * The derived list is a pure function of the envelope and the file's filesystem
1661
- * identity, so it is computed at most once per generation per file: sibling and
1662
- * repeated deliveries replay the per-envelope memo ({@link envelopeDerivation})
1663
- * instead of re-walking the graph. Returns an empty list on exceptions.
2153
+ * The derived list is a pure function of the envelope and the delivered file's
2154
+ * normalized lexical spelling. Graph traversal uses its filesystem identity,
2155
+ * but lexical inputs exclude only that exact spelling, so two aliases of one
2156
+ * source require distinct lists. Repeated deliveries of one spelling replay the
2157
+ * per-envelope memo ({@link envelopeDerivation}) instead of re-walking the
2158
+ * graph. Returns an empty list on exceptions.
1664
2159
  */
1665
2160
  function selectWatchInputs(props: {
1666
2161
  file: string;
@@ -1674,12 +2169,13 @@ function selectWatchInputs(props: {
1674
2169
  }
1675
2170
  const state = envelopeDerivation(props);
1676
2171
  const fileIdentity = derivationIdentity(state, props.file);
1677
- const memoized = state.watchInputs.get(fileIdentity);
2172
+ const fileSpelling = path.resolve(props.file);
2173
+ const memoized = state.watchInputs.get(fileSpelling);
1678
2174
  if (memoized !== undefined) {
1679
2175
  return memoized;
1680
2176
  }
1681
2177
  const derived = deriveWatchInputs(state, props, fileIdentity);
1682
- state.watchInputs.set(fileIdentity, derived);
2178
+ state.watchInputs.set(fileSpelling, derived);
1683
2179
  return derived;
1684
2180
  }
1685
2181
 
@@ -1738,7 +2234,7 @@ function deriveWatchInputs(
1738
2234
  !isVolatileFile(state, props),
1739
2235
  }))
1740
2236
  appendPhysical(input);
1741
- // Resolution candidates, plugin dependencies, and universal host inputs
2237
+ // Resolver inputs, plugin dependencies, and universal host inputs
1742
2238
  // preserve lexical aliases. Physical deduplication would collapse
1743
2239
  // `alias/selection.cjs` into the selected target path, so a bundler would
1744
2240
  // watch only the target and miss a symlink/junction retarget.
@@ -1759,14 +2255,14 @@ function selectHostInputs(props: {
1759
2255
  }
1760
2256
 
1761
2257
  /**
1762
- * Return the module-resolution paths that can supersede a currently resolved
1763
- * module reachable from `file`. They remain host-owned even when a plugin
1764
- * declares `dependenciesComplete`: plugin code cannot vouch for a compiler
1765
- * resolution change that occurs without any plugin input changing.
2258
+ * Return exact importer-owned and universal resolver inputs for `file`. They
2259
+ * remain host-owned even when a plugin declares `dependenciesComplete`: plugin
2260
+ * code cannot vouch for a compiler resolution or automatic type change that
2261
+ * occurs without any plugin input changing.
1766
2262
  *
1767
- * Candidate entries and their source identities come from the shared
1768
- * per-envelope state, so one delivery scans only the candidates themselves
1769
- * instead of re-resolving every candidate source.
2263
+ * Importer entries and their source identities come from the shared
2264
+ * per-envelope state, so one delivery scans only the recorded inputs instead of
2265
+ * re-resolving every source.
1770
2266
  */
1771
2267
  function selectResolutionCandidateInputs(
1772
2268
  graph: TtscEnvelopeGraphIndexes,
@@ -1777,10 +2273,7 @@ function selectResolutionCandidateInputs(
1777
2273
  result: ITtscCompilerTransformation;
1778
2274
  },
1779
2275
  ): string[] {
1780
- if (
1781
- props.result.type === "exception" ||
1782
- props.result.graph?.candidates === undefined
1783
- ) {
2276
+ if (props.result.type === "exception" || props.result.graph === undefined) {
1784
2277
  return [];
1785
2278
  }
1786
2279
  const reachable = new Set(
@@ -1788,7 +2281,8 @@ function selectResolutionCandidateInputs(
1788
2281
  derivationIdentity(state, source),
1789
2282
  ),
1790
2283
  );
1791
- const output: string[] = [];
2284
+ const output: string[] = [...graph.resolutionInputs];
2285
+ if (props.result.graph.candidates === undefined) return output;
1792
2286
  for (const entry of graph.candidates) {
1793
2287
  if (!reachable.has(entry.source)) {
1794
2288
  continue;
@@ -1963,9 +2457,11 @@ function declaresCompleteDependencies(
1963
2457
  }
1964
2458
 
1965
2459
  /**
1966
- * Extract the absolute, deduplicated dependency list for a single file from the
1967
- * compiler result. Mirrors {@link selectTransformedSource}'s key lookup: fast
1968
- * project-relative match first, then a per-envelope identity index. Returns an
2460
+ * Extract the absolute, lexical-spelling-deduplicated dependency list for a
2461
+ * single file from the compiler result. Mirrors
2462
+ * {@link selectTransformedSource}'s key lookup: fast project-relative match
2463
+ * first, then a per-envelope identity index. Distinct lexical aliases must
2464
+ * survive so bundlers observe a later symlink or junction retarget. Returns an
1969
2465
  * empty list on exceptions or when the plugin reported nothing.
1970
2466
  */
1971
2467
  function selectFileDependencies(props: {
@@ -2002,17 +2498,15 @@ function selectFileDependencies(props: {
2002
2498
  }
2003
2499
  const output: string[] = [];
2004
2500
  const seen = new Set<string>();
2005
- const fileIdentity = derivationIdentity(state, props.file);
2006
2501
  for (const entry of entries) {
2007
2502
  if (typeof entry !== "string" || entry.length === 0) {
2008
2503
  continue;
2009
2504
  }
2010
2505
  const absolute = path.resolve(props.projectRoot, entry);
2011
- const identity = derivationIdentity(state, absolute);
2012
- if (identity === fileIdentity || seen.has(identity)) {
2506
+ if (seen.has(absolute)) {
2013
2507
  continue;
2014
2508
  }
2015
- seen.add(identity);
2509
+ seen.add(absolute);
2016
2510
  output.push(absolute);
2017
2511
  }
2018
2512
  return output;
@@ -2141,6 +2635,10 @@ function matchesCachedSource(
2141
2635
  // out-of-walk snapshot — before any of this pass's deliveries may be
2142
2636
  // settled against it. That proof is what a per-pass recompile used to buy
2143
2637
  // (samchon/ttsc#1300), at a walk instead of a compile.
2638
+ refreshFilesystemClockReference(
2639
+ TRANSFORM_CLOCK_REFERENCE_DIRECTORIES.get(cached),
2640
+ resultFilesystem(cached.result),
2641
+ );
2144
2642
  if (!matchesCompleteInputSnapshot(cached, currentKey, source)) {
2145
2643
  return false;
2146
2644
  }
@@ -2152,6 +2650,10 @@ function matchesCachedSource(
2152
2650
  return true;
2153
2651
  }
2154
2652
  }
2653
+ refreshFilesystemClockReference(
2654
+ TRANSFORM_CLOCK_REFERENCE_DIRECTORIES.get(cached),
2655
+ resultFilesystem(cached.result),
2656
+ );
2155
2657
  if (
2156
2658
  cached.result.type !== "exception" &&
2157
2659
  cached.result.graph !== undefined &&
@@ -2225,20 +2727,21 @@ function matchesNarrowPersistentInputs(
2225
2727
 
2226
2728
  /**
2227
2729
  * Validate one derived input against the generation, skipping the content read
2228
- * while the recorded metadata signature still holds.
2730
+ * while the recorded metadata signature still holds and its freshly minted
2731
+ * clock reference remains safe.
2229
2732
  *
2230
2733
  * Sibling deliveries of one generation share most of their derived inputs, and
2231
2734
  * `graph.globals` is shared by every one of them, so re-reading and re-hashing
2232
2735
  * the whole derived set per delivery multiplies one generation's proven bytes
2233
2736
  * by the module count. The derived set is proven the same way the universal
2234
2737
  * descriptor inputs are ({@link matchesUniversalHostInputs}), under the same
2235
- * rules: an unchanged signature stands in for the content comparison, and any
2236
- * signature change falls back to the full comparison. A signature is recorded
2237
- * only around a read nothing raced, only for a recorded state that came from
2238
- * reading the input rather than from failing to, and only while the observed
2239
- * filesystem's own clock has provably left the stamp's tick
2240
- * ({@link stampSeparable}), so a same-length rewrite inside that tick cannot
2241
- * hide behind an unchanged signature.
2738
+ * rules: a currently separable unchanged signature stands in for the content
2739
+ * comparison, and any signature or clock-ordering change falls back to the full
2740
+ * comparison. A signature is recorded only around a read nothing raced, only
2741
+ * for a recorded state that came from reading the input rather than from
2742
+ * failing to, and only while the observed filesystem's own clock has provably
2743
+ * left the stamp's tick ({@link stampSeparable}), so a same-length rewrite
2744
+ * inside that tick cannot hide behind an unchanged signature.
2242
2745
  *
2243
2746
  * The signature carries the physical identity of both the lexical path and its
2244
2747
  * link target ({@link inputMetadataSignature}), so retargeting a symlink or
@@ -2249,6 +2752,17 @@ function matchesProvenInput(
2249
2752
  state: TtscEnvelopeDerivation,
2250
2753
  input: string,
2251
2754
  ): boolean {
2755
+ const observation = cached.externalInputObservations?.[path.resolve(input)];
2756
+ if (observation !== undefined) {
2757
+ if (
2758
+ observation.fileExists === false &&
2759
+ Object.keys(observation).length === 1 &&
2760
+ notifiesAbsence(cached, input)
2761
+ ) {
2762
+ return true;
2763
+ }
2764
+ return matchesRecordedInput(cached, input);
2765
+ }
2252
2766
  const slot = inputSignatureSlot(cached, state, input);
2253
2767
  if (slot === undefined) {
2254
2768
  return matchesRecordedInput(cached, input);
@@ -2263,7 +2777,11 @@ function matchesProvenInput(
2263
2777
  }
2264
2778
  const filesystem = resultFilesystem(cached.result);
2265
2779
  const before = inputMetadataEvidence(input, filesystem);
2266
- if (before !== undefined && slot.signatures[slot.key] === before.signature) {
2780
+ if (
2781
+ before !== undefined &&
2782
+ before.separable &&
2783
+ slot.signatures[slot.key] === before.signature
2784
+ ) {
2267
2785
  return true;
2268
2786
  }
2269
2787
  if (!matchesRecordedInput(cached, input)) {
@@ -2325,6 +2843,9 @@ function inputSignatureSlot(
2325
2843
  | { key: string; recorded: string; signatures: Record<string, string> }
2326
2844
  | undefined {
2327
2845
  const identity = derivationIdentity(state, input);
2846
+ if (cached.externalInputObservations?.[path.resolve(input)] !== undefined) {
2847
+ return undefined;
2848
+ }
2328
2849
  const external = cached.externalInputHashes ?? {};
2329
2850
  if (Object.prototype.hasOwnProperty.call(external, identity)) {
2330
2851
  // The recorded hash is identity-keyed because aliases of one physical file
@@ -2384,7 +2905,8 @@ function matchesUniversalHostInputEntries(
2384
2905
  const evidence = inputMetadataEvidence(entry.path, filesystem);
2385
2906
  if (
2386
2907
  entry.signature !== undefined &&
2387
- evidence?.signature === entry.signature
2908
+ evidence?.signature === entry.signature &&
2909
+ (entry.strict === true || evidence.separable)
2388
2910
  )
2389
2911
  continue;
2390
2912
  if (entry.strict === true) return false;
@@ -2644,8 +3166,8 @@ function captureUniversalHostInputValidation(
2644
3166
  const MISSING_INPUT_STATE = "missing";
2645
3167
 
2646
3168
  /**
2647
- * The highest stamp each observed filesystem clock has provably minted, keyed
2648
- * by the operations object that observes it and, inside, by reporting device.
3169
+ * The current stamp an adapter-owned probe has minted, keyed by the operations
3170
+ * object that observes it and, inside, by reporting device.
2649
3171
  *
2650
3172
  * A filesystem stamps a write once per clock tick, so two same-length writes
2651
3173
  * inside one tick are indistinguishable by metadata alone. A signature may
@@ -2653,72 +3175,46 @@ const MISSING_INPUT_STATE = "missing";
2653
3175
  * it, and that guarantee needs a reference instant the observed filesystem
2654
3176
  * itself produced: once some stamp on the same device is strictly newer than an
2655
3177
  * input's modification stamp, that input's tick is provably over, so any later
2656
- * write must mint a newer stamp and move the signature. That is git's
2657
- * racily-clean index rule, adapted to a read-only contract: where git compares
2658
- * entries against the index file's own timestamp, this floor accumulates every
2659
- * stamp the cache-owned operations report, seeded per generation by
2660
- * {@link mintFilesystemClockReference}.
3178
+ * write must mint a newer stamp and move the signature. This adapts git's
3179
+ * racily-clean index rule: both sides of the comparison come from the same
3180
+ * reporting device, and the newer side is a write the adapter itself made.
2661
3181
  *
2662
- * The process clock never participates: both sides of every comparison are
2663
- * stamps the same filesystem clock minted, at the same granularity, so a
2664
- * filesystem clock running behind (or ahead of) the host process changes
2665
- * nothing.
3182
+ * A timestamp merely observed on another input cannot advance the reference.
3183
+ * Tools may assign modification times, and a filesystem clock can move
3184
+ * backwards independently of the process clock. Neither a passive historical
3185
+ * maximum nor `Date.now()` proves what stamp a write would mint now. The probe
3186
+ * is therefore rewritten immediately before every validation that may reuse a
3187
+ * content signature, and its previous reference is cleared before the write. A
3188
+ * failed refresh or a different reporting device declines the optimization and
3189
+ * retains the content comparison (samchon/ttsc#1344).
2666
3190
  *
2667
- * Accumulating observed stamps is deliberately weaker than git's own reference,
2668
- * which is a single stamp git minted itself. A stamp this floor accepts may
2669
- * instead have been _set_ rather than minted, and a set stamp is dangerous only
2670
- * when it lands in the future: the floor is a maximum, so a restored past stamp
2671
- * never raises it. One future-dated file — a stamp-preserving extraction or
2672
- * copy from a machine whose clock ran ahead — pushes its device's floor past
2673
- * the present and reopens the same-tick window for every other input on that
2674
- * device until the clock catches up. A clock that jumps backwards strands the
2675
- * floor above the present the same way, a different hazard from the constant
2676
- * offset the paragraph above is about: an offset moves both operands together
2677
- * and changes nothing, a jump moves only the present.
2678
- *
2679
- * The minted probe is not enough on its own to replace observed stamps: it
2680
- * lands on the scratch volume, which is frequently not the inputs' volume (a
2681
- * project on `D:` with `TEMP` on `C:`), and a probe-only floor would then
2682
- * decline every _content_ signature, so every input carrying bytes would be
2683
- * re-read on every delivery. A strict blocker keeps its signature either way,
2684
- * because it proves a kind rather than content. Observed stamps keep the common
2685
- * case working; the probe covers the case they cannot, a tree whose files were
2686
- * all written inside one tick.
3191
+ * The probe lives in an adapter-owned temporary directory outside the project.
3192
+ * That directory may be on another volume, such as `C:` when a project lives on
3193
+ * `D:`. Such a split-volume generation safely keeps reading content because no
3194
+ * same-device reference exists; it never substitutes a process-clock guess or
3195
+ * writes a probe into the user's project.
2687
3196
  */
2688
- const FILESYSTEM_CLOCK_FLOORS = new WeakMap<
3197
+ const FILESYSTEM_CLOCK_REFERENCES = new WeakMap<
2689
3198
  TtscTransformFilesystemOperations,
2690
3199
  Map<bigint, bigint>
2691
3200
  >();
2692
3201
 
2693
- /** Return one observed filesystem's per-device clock floor, creating it. */
2694
- function filesystemClockFloors(
3202
+ /** Return one observed filesystem's current per-device references. */
3203
+ function filesystemClockReferences(
2695
3204
  filesystem: TtscTransformFilesystemOperations,
2696
3205
  ): Map<bigint, bigint> {
2697
- let floors = FILESYSTEM_CLOCK_FLOORS.get(filesystem);
2698
- if (floors === undefined) {
2699
- floors = new Map();
2700
- FILESYSTEM_CLOCK_FLOORS.set(filesystem, floors);
2701
- }
2702
- return floors;
2703
- }
2704
-
2705
- /** Raise a device's clock floor with the stamps one observation reported. */
2706
- function observeFilesystemClock(
2707
- filesystem: TtscTransformFilesystemOperations,
2708
- stats: fs.BigIntStats,
2709
- ): void {
2710
- const floors = filesystemClockFloors(filesystem);
2711
- const stamp = stats.mtimeNs > stats.ctimeNs ? stats.mtimeNs : stats.ctimeNs;
2712
- const current = floors.get(stats.dev);
2713
- if (current === undefined || stamp > current) {
2714
- floors.set(stats.dev, stamp);
3206
+ let references = FILESYSTEM_CLOCK_REFERENCES.get(filesystem);
3207
+ if (references === undefined) {
3208
+ references = new Map();
3209
+ FILESYSTEM_CLOCK_REFERENCES.set(filesystem, references);
2715
3210
  }
3211
+ return references;
2716
3212
  }
2717
3213
 
2718
3214
  /**
2719
3215
  * Report whether a later write to the observed path is guaranteed to move its
2720
- * modification stamp: the device's clock floor holds a stamp strictly newer, so
2721
- * the tick that minted the stamp is provably over. The floor was observed
3216
+ * modification stamp: the device's current probe holds a stamp strictly newer,
3217
+ * so the tick that minted the stamp is provably over. The probe was written
2722
3218
  * before the caller's content read began, which is the ordering the guarantee
2723
3219
  * needs — a stamp minted before the read proves every post-read write lands in
2724
3220
  * a newer tick.
@@ -2727,42 +3223,62 @@ function stampSeparable(
2727
3223
  filesystem: TtscTransformFilesystemOperations,
2728
3224
  stats: fs.BigIntStats,
2729
3225
  ): boolean {
2730
- const floor = filesystemClockFloors(filesystem).get(stats.dev);
2731
- return floor !== undefined && stats.mtimeNs < floor;
3226
+ const reference = filesystemClockReferences(filesystem).get(stats.dev);
3227
+ return reference !== undefined && stats.mtimeNs < reference;
2732
3228
  }
2733
3229
 
2734
3230
  /**
2735
- * Mint a reference instant for this generation and feed it into the observed
2736
- * filesystem's clock floor.
3231
+ * Replace every prior clock proof with one reference minted by a fresh write.
2737
3232
  *
2738
- * The scratch directory is a write the adapter already owns, deliberately
3233
+ * The reference directory is storage the adapter already owns, deliberately
2739
3234
  * outside the project root, so stamping a probe file there produces a
2740
3235
  * freshly-minted "now" without touching the user's project — the analogue of
2741
3236
  * git writing its index. The probe is observed through the cache-owned
2742
3237
  * operations and keyed by the device those operations report, so it only ever
2743
- * separates stamps on the filesystem that actually minted it; when the scratch
2744
- * volume differs from the inputs' volume, or the observed filesystem cannot see
2745
- * the probe at all, nothing is proven and signature recording simply stays
2746
- * declined until passively observed stamps separate an input on their own.
3238
+ * separates stamps on the filesystem that actually minted it. When its volume
3239
+ * differs from the inputs' volume, or the observed filesystem cannot see the
3240
+ * probe at all, nothing is proven and content comparison continues.
2747
3241
  *
2748
- * Relocating the scratch directory onto the inputs' volume would make the probe
2749
- * universal, but it would also move every compiler and plugin temporary write
2750
- * into the project's parent (frequently a monorepo root or a home directory)
2751
- * for those layouts. That is a product decision about where ttsc writes, not a
2752
- * property of this rule, so the cross-volume case degrades to more reads here
2753
- * rather than being bought with it.
3242
+ * Forcing the reference onto the inputs' volume would require writing into the
3243
+ * user's project or an otherwise unowned neighboring directory. That is not a
3244
+ * valid price for this optimization, so the cross-volume case degrades to more
3245
+ * reads instead.
2754
3246
  */
2755
- function mintFilesystemClockReference(
2756
- scratchDirectory: string,
3247
+ function refreshFilesystemClockReference(
3248
+ referenceDirectory: string | undefined,
2757
3249
  filesystem: TtscTransformFilesystemOperations,
2758
3250
  ): void {
3251
+ const references = filesystemClockReferences(filesystem);
3252
+ references.clear();
3253
+ if (referenceDirectory === undefined) return;
3254
+ try {
3255
+ const probe = path.join(referenceDirectory, "clock-reference");
3256
+ fs.writeFileSync(probe, `${process.hrtime.bigint()}\n`);
3257
+ const stats = filesystem.lstat(probe);
3258
+ if (stats.isFile()) {
3259
+ references.set(stats.dev, stats.mtimeNs);
3260
+ }
3261
+ } catch {
3262
+ // A failed refresh leaves no reference, so content comparison continues.
3263
+ references.clear();
3264
+ }
3265
+ }
3266
+
3267
+ /** Remove only the known probe and its now-empty owned directory. */
3268
+ function disposeFilesystemClockReference(referenceDirectory: string): void {
3269
+ try {
3270
+ fs.rmSync(path.join(referenceDirectory, "clock-reference"), {
3271
+ force: true,
3272
+ });
3273
+ } catch {
3274
+ // The probe may already have disappeared; the directory removal below is
3275
+ // still safe because it is deliberately non-recursive.
3276
+ }
2759
3277
  try {
2760
- const probe = path.join(scratchDirectory, "clock-reference");
2761
- fs.writeFileSync(probe, "");
2762
- observeFilesystemClock(filesystem, filesystem.lstat(probe));
3278
+ fs.rmdirSync(referenceDirectory);
2763
3279
  } catch {
2764
- // The absence of a reference declines signature recording; it never
2765
- // invalidates a generation.
3280
+ // Eviction schedules cleanup without awaiting its Promise. A foreign entry
3281
+ // or a concurrent removal leaves, at worst, an empty temporary directory.
2766
3282
  }
2767
3283
  }
2768
3284
 
@@ -2797,12 +3313,10 @@ function inputMetadataEvidence(
2797
3313
  ): TtscInputMetadataEvidence | undefined {
2798
3314
  try {
2799
3315
  const link = filesystem.lstat(file);
2800
- observeFilesystemClock(filesystem, link);
2801
3316
  let target = link;
2802
3317
  if (link.isSymbolicLink()) {
2803
3318
  try {
2804
3319
  target = filesystem.statBigInt(file);
2805
- observeFilesystemClock(filesystem, target);
2806
3320
  } catch {
2807
3321
  // Keep a broken link in the existing-input manifest. Its own metadata
2808
3322
  // stays stable while the target is missing, and the first successful
@@ -2873,7 +3387,21 @@ function graphInputStateHash(
2873
3387
  file: string,
2874
3388
  filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
2875
3389
  ): string | null {
3390
+ const kind = compilerStatKind(file, filesystem);
3391
+ const readHash = graphInputReadHash(file, filesystem, kind);
3392
+ if (readHash !== null) return readHash;
3393
+ return kind === "directory" ? hashText("ttsc:host-input:directory\0") : null;
3394
+ }
3395
+
3396
+ /** Hash only a successful compiler-style ReadFile result, without kind fallback. */
3397
+ function graphInputReadHash(
3398
+ file: string,
3399
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
3400
+ observedKind?: "directory" | "file" | "missing",
3401
+ ): string | null {
3402
+ const kind = observedKind ?? compilerStatKind(file, filesystem);
2876
3403
  try {
3404
+ if (kind === "directory") return null;
2877
3405
  const bytes = filesystem.readFile(file);
2878
3406
  if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
2879
3407
  const even = bytes.subarray(
@@ -2898,14 +3426,179 @@ function graphInputStateHash(
2898
3426
  : bytes;
2899
3427
  return hashText(content);
2900
3428
  } catch {
3429
+ return null;
3430
+ }
3431
+ }
3432
+
3433
+ /** Current result of TypeScript-Go's Stat predicate. */
3434
+ function compilerStatKind(
3435
+ file: string,
3436
+ filesystem: TtscTransformFilesystemOperations,
3437
+ ): "directory" | "file" | "missing" {
3438
+ try {
3439
+ return filesystem.stat(file).isDirectory() ? "directory" : "file";
3440
+ } catch {
3441
+ return "missing";
3442
+ }
3443
+ }
3444
+
3445
+ /** Return the exact recorded predicates that no longer hold for one spelling. */
3446
+ function graphInputObservationFailures(
3447
+ file: string,
3448
+ observation: ITtscCompilerTransformation.IInputObservation,
3449
+ filesystem: TtscTransformFilesystemOperations,
3450
+ identities: FilesystemPathIdentityContext,
3451
+ ): string[] {
3452
+ const failures: string[] = [];
3453
+ if (observation.accessibleEntries !== undefined) {
3454
+ const current = compilerAccessibleEntries(file, filesystem);
3455
+ if (
3456
+ JSON.stringify(current) !== JSON.stringify(observation.accessibleEntries)
3457
+ ) {
3458
+ failures.push("accessible-entries-changed");
3459
+ }
3460
+ }
3461
+ const kind =
3462
+ observation.fileExists !== undefined ||
3463
+ observation.directoryExists !== undefined ||
3464
+ observation.stat !== undefined ||
3465
+ observation.readFile !== undefined
3466
+ ? compilerStatKind(file, filesystem)
3467
+ : undefined;
3468
+ if (
3469
+ observation.fileExists !== undefined &&
3470
+ (kind === "file") !== observation.fileExists
3471
+ ) {
3472
+ failures.push("file-exists-changed");
3473
+ }
3474
+ if (
3475
+ observation.directoryExists !== undefined &&
3476
+ (kind === "directory") !== observation.directoryExists
3477
+ ) {
3478
+ failures.push("directory-exists-changed");
3479
+ }
3480
+ if (observation.stat !== undefined && kind !== observation.stat) {
3481
+ failures.push("stat-changed");
3482
+ }
3483
+ if (observation.readFile !== undefined) {
3484
+ const currentHash = graphInputReadHash(file, filesystem, kind);
3485
+ if (
3486
+ (observation.readFile.ok && currentHash !== observation.readFile.hash) ||
3487
+ (!observation.readFile.ok && currentHash !== null)
3488
+ ) {
3489
+ failures.push("read-file-changed");
3490
+ }
3491
+ }
3492
+ if (observation.realpath !== undefined) {
3493
+ const currentRealpath = compilerInputRealpathObservation(file, filesystem);
3494
+ if (
3495
+ observation.realpath.ok !== currentRealpath.ok ||
3496
+ (observation.realpath.ok &&
3497
+ currentRealpath.ok &&
3498
+ !sameHostInputRealpath(
3499
+ observation.realpath.path,
3500
+ currentRealpath.path,
3501
+ identities,
3502
+ ))
3503
+ ) {
3504
+ failures.push("realpath-changed");
3505
+ }
3506
+ }
3507
+ return failures;
3508
+ }
3509
+
3510
+ /** Replay TypeScript-Go's accessible-entry classification and sorted order. */
3511
+ function compilerAccessibleEntries(
3512
+ directory: string,
3513
+ filesystem: TtscTransformFilesystemOperations,
3514
+ ): NonNullable<
3515
+ ITtscCompilerTransformation.IInputObservation["accessibleEntries"]
3516
+ > {
3517
+ const directories: string[] = [];
3518
+ const files: string[] = [];
3519
+ const pathApi = filesystem.platform === "win32" ? path.win32 : path.posix;
3520
+ let entries: fs.Dirent[];
3521
+ try {
3522
+ entries = filesystem.readdir(directory);
3523
+ } catch {
3524
+ return { directories, files };
3525
+ }
3526
+ for (const entry of entries) {
3527
+ if (entry.isDirectory()) {
3528
+ directories.push(entry.name);
3529
+ continue;
3530
+ }
3531
+ if (entry.isFile()) {
3532
+ files.push(entry.name);
3533
+ continue;
3534
+ }
3535
+ if (!entry.isSymbolicLink()) continue;
2901
3536
  try {
2902
- return filesystem.stat(file).isDirectory()
2903
- ? hashText("ttsc:host-input:directory\0")
2904
- : null;
3537
+ const target = filesystem.stat(pathApi.join(directory, entry.name));
3538
+ if (target.isDirectory()) directories.push(entry.name);
3539
+ else if (target.isFile()) files.push(entry.name);
2905
3540
  } catch {
2906
- return null;
3541
+ // TypeScript-Go omits inaccessible and broken linked entries.
2907
3542
  }
2908
3543
  }
3544
+ directories.sort();
3545
+ files.sort();
3546
+ return { directories, files };
3547
+ }
3548
+
3549
+ /** Validate one predicate-preserving graph proof against a filesystem view. */
3550
+ export function validateGraphInputObservation(
3551
+ file: string,
3552
+ observation: ITtscCompilerTransformation.IInputObservation,
3553
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
3554
+ ): string[] {
3555
+ const normalized = normalizeGraphInputObservation(
3556
+ observation,
3557
+ filesystem.platform,
3558
+ );
3559
+ if (normalized === undefined) return ["proof-conflict"];
3560
+ return graphInputObservationFailures(
3561
+ file,
3562
+ normalized,
3563
+ filesystem,
3564
+ createHostPathIdentityContext(filesystem),
3565
+ );
3566
+ }
3567
+
3568
+ /** Whether every compiler-time predicate still returns its recorded value. */
3569
+ function matchesGraphInputObservation(
3570
+ file: string,
3571
+ observation: ITtscCompilerTransformation.IInputObservation,
3572
+ filesystem: TtscTransformFilesystemOperations,
3573
+ identities: FilesystemPathIdentityContext,
3574
+ ): boolean {
3575
+ return (
3576
+ graphInputObservationFailures(file, observation, filesystem, identities)
3577
+ .length === 0
3578
+ );
3579
+ }
3580
+
3581
+ /** Replay TypeScript-Go's Realpath result, including its lexical fallback. */
3582
+ function compilerInputRealpathObservation(
3583
+ file: string,
3584
+ filesystem: TtscTransformFilesystemOperations,
3585
+ ): NonNullable<ITtscCompilerTransformation.IInputObservation["realpath"]> {
3586
+ try {
3587
+ const realpath = filesystem.realpath(file);
3588
+ return realpath.length === 0
3589
+ ? { ok: false }
3590
+ : {
3591
+ ok: true,
3592
+ path: resolveFilesystemPath(realpath, filesystem.platform),
3593
+ };
3594
+ } catch {
3595
+ // TypeScript-Go's OS and io filesystems return the cleaned input spelling
3596
+ // when native realpath resolution fails; they do not expose the failure.
3597
+ return {
3598
+ ok: true,
3599
+ path: resolveFilesystemPath(file, filesystem.platform),
3600
+ };
3601
+ }
2909
3602
  }
2910
3603
 
2911
3604
  /** Physical target selected by a lexical host-input path. */
@@ -3093,6 +3786,9 @@ function matchesExternalInputRealpaths(
3093
3786
  const state = envelopeDerivation(cached);
3094
3787
  const filesystem = resultFilesystem(cached.result);
3095
3788
  for (const input of cached.externalInputPaths ?? []) {
3789
+ if (cached.externalInputObservations?.[path.resolve(input)] !== undefined) {
3790
+ continue;
3791
+ }
3096
3792
  const identity = derivationIdentity(state, input);
3097
3793
  if (!Object.prototype.hasOwnProperty.call(expected, identity)) continue;
3098
3794
  if (
@@ -3122,6 +3818,7 @@ function captureExternalInputSnapshot(
3122
3818
  complete: boolean;
3123
3819
  failures: TtscGenerationProofFailures;
3124
3820
  hashes: Record<string, string>;
3821
+ observations: Record<string, ITtscCompilerTransformation.IInputObservation>;
3125
3822
  realpaths: Record<string, string | null>;
3126
3823
  signatures: Record<string, string>;
3127
3824
  } {
@@ -3131,17 +3828,19 @@ function captureExternalInputSnapshot(
3131
3828
  // A non-declaration transform output is a compiler-realized source even when
3132
3829
  // a malformed or legacy graph omitted its node. Its output was computed from
3133
3830
  // compiler-time bytes, so a post-compile host read cannot prove coherence.
3134
- const transformSources = new Set<string>();
3831
+ const transformSourceSpellings = new Set<string>();
3135
3832
  if (cached.result.type === "success") {
3136
3833
  for (const output of Object.keys(cached.result.typescript)) {
3137
3834
  if (!isDeclarationFile(output)) {
3138
- transformSources.add(
3139
- derivationIdentity(state, path.resolve(cached.projectRoot, output)),
3140
- );
3835
+ transformSourceSpellings.add(path.resolve(cached.projectRoot, output));
3141
3836
  }
3142
3837
  }
3143
3838
  }
3144
3839
  const hashes: Record<string, string> = {};
3840
+ const observations: Record<
3841
+ string,
3842
+ ITtscCompilerTransformation.IInputObservation
3843
+ > = {};
3145
3844
  const realpaths: Record<string, string | null> = {};
3146
3845
  const signatures: Record<string, string> = {};
3147
3846
  const failures = createGenerationProofFailures();
@@ -3162,29 +3861,64 @@ function captureExternalInputSnapshot(
3162
3861
  };
3163
3862
  for (const input of paths) {
3164
3863
  const identity = derivationIdentity(state, input);
3165
- // A member the envelope reported only as a resolution candidate falls
3864
+ const spelling = path.resolve(input);
3865
+ const predicateObservation = graph.inputObservations.get(spelling);
3866
+ const predicateConflict = graph.inputObservationConflicts.has(spelling);
3867
+ if (
3868
+ graph.speculative.has(spelling) &&
3869
+ (predicateObservation !== undefined || predicateConflict)
3870
+ ) {
3871
+ if (predicateConflict || predicateObservation === undefined) {
3872
+ complete = false;
3873
+ recordGenerationProofFailure(failures, {
3874
+ domain: "external",
3875
+ kind: "graph-proof-conflict",
3876
+ detail: graph.inputProofFailures.get(spelling),
3877
+ path: input,
3878
+ });
3879
+ continue;
3880
+ }
3881
+ const mismatches = graphInputObservationFailures(
3882
+ input,
3883
+ predicateObservation,
3884
+ filesystem,
3885
+ state.identityContext,
3886
+ );
3887
+ if (mismatches.length !== 0) complete = false;
3888
+ for (const kind of mismatches) {
3889
+ recordGenerationProofFailure(failures, {
3890
+ domain: "external",
3891
+ kind: `graph-${kind}`,
3892
+ path: input,
3893
+ });
3894
+ }
3895
+ observations[spelling] = predicateObservation;
3896
+ continue;
3897
+ }
3898
+ // A member the envelope reported only as a resolver input falls
3166
3899
  // through to the recorded-state branch below, the same evidence a
3167
3900
  // plugin-declared dependency path carries. Its absence still invalidates
3168
3901
  // the generation when it appears, because `missing` is recorded state.
3169
- const realizedTransformSource = transformSources.has(identity);
3902
+ const realizedTransformSource = transformSourceSpellings.has(spelling);
3170
3903
  const speculativeOnly =
3171
3904
  !realizedTransformSource &&
3172
- graph.speculative.has(identity) &&
3173
- !graph.inputProofs.has(identity) &&
3174
- !graph.inputProofConflicts.has(identity);
3905
+ graph.speculative.has(spelling) &&
3906
+ !graph.inputProofs.has(spelling) &&
3907
+ !graph.inputProofConflicts.has(spelling) &&
3908
+ !graph.inputProofFailures.has(spelling);
3175
3909
  if (
3176
- (realizedTransformSource || graph.members.has(identity)) &&
3910
+ (realizedTransformSource || graph.memberSpellings.has(spelling)) &&
3177
3911
  !speculativeOnly
3178
3912
  ) {
3179
- const proof = graph.inputProofs.get(identity);
3180
- if (proof === undefined || graph.inputProofConflicts.has(identity)) {
3913
+ const proof = graph.inputProofs.get(spelling);
3914
+ if (proof === undefined || graph.inputProofConflicts.has(spelling)) {
3181
3915
  complete = false;
3182
3916
  recordGenerationProofFailure(failures, {
3183
3917
  domain: "external",
3184
- kind: graph.inputProofConflicts.has(identity)
3918
+ kind: graph.inputProofConflicts.has(spelling)
3185
3919
  ? "graph-proof-conflict"
3186
3920
  : "graph-proof-missing",
3187
- detail: graph.inputProofFailures.get(identity),
3921
+ detail: graph.inputProofFailures.get(spelling),
3188
3922
  path: input,
3189
3923
  });
3190
3924
  continue;
@@ -3231,7 +3965,14 @@ function captureExternalInputSnapshot(
3231
3965
  hashes[identity] = hash ?? MISSING_INPUT_STATE;
3232
3966
  if (hash !== null) record(input, before, after);
3233
3967
  }
3234
- return { complete, failures, hashes, realpaths, signatures };
3968
+ return {
3969
+ complete,
3970
+ failures,
3971
+ hashes,
3972
+ observations,
3973
+ realpaths,
3974
+ signatures,
3975
+ };
3235
3976
  }
3236
3977
 
3237
3978
  /** Explain every graph member that no longer matches the compiler's state. */
@@ -3243,6 +3984,7 @@ function compilerGraphInputProofFailures(
3243
3984
  cached.result.type === "exception" ||
3244
3985
  cached.result.graph === undefined ||
3245
3986
  (cached.result.graph.inputHashes === undefined &&
3987
+ cached.result.graph.inputObservations === undefined &&
3246
3988
  cached.result.graph.inputRealpaths === undefined &&
3247
3989
  cached.result.graph.inputProofFailures === undefined)
3248
3990
  ) {
@@ -3251,38 +3993,105 @@ function compilerGraphInputProofFailures(
3251
3993
  // where a post-compile snapshot cannot prove the compiler's generation.
3252
3994
  return failures;
3253
3995
  }
3254
- const state = envelopeDerivation(cached);
3255
- const filesystem = resultFilesystem(cached.result);
3256
- const graph = envelopeGraphIndexes(state, cached);
3257
- for (const identity of graph.members) {
3258
- const proof = graph.inputProofs.get(identity);
3259
- const spelling =
3260
- proof?.path ?? graph.spellings.get(identity) ?? cached.projectRoot;
3996
+ const state = envelopeDerivation(cached);
3997
+ const filesystem = resultFilesystem(cached.result);
3998
+ const graph = envelopeGraphIndexes(state, cached);
3999
+ const predicateSpellings = new Set<string>();
4000
+ const predicateConflictSpellings = new Set<string>();
4001
+ for (const [spelling, observation] of graph.inputObservations) {
4002
+ predicateSpellings.add(spelling);
4003
+ for (const kind of graphInputObservationFailures(
4004
+ spelling,
4005
+ observation,
4006
+ filesystem,
4007
+ state.identityContext,
4008
+ )) {
4009
+ recordGenerationProofFailure(failures, {
4010
+ domain: "graph",
4011
+ kind,
4012
+ path: spelling,
4013
+ });
4014
+ }
4015
+ }
4016
+ for (const spelling of graph.inputObservationConflicts) {
4017
+ predicateSpellings.add(spelling);
4018
+ predicateConflictSpellings.add(spelling);
4019
+ recordGenerationProofFailure(failures, {
4020
+ domain: "graph",
4021
+ kind: "proof-conflict",
4022
+ detail: graph.inputProofFailures.get(spelling),
4023
+ path: spelling,
4024
+ });
4025
+ }
4026
+ for (const spelling of graph.memberSpellings) {
4027
+ const proof = graph.inputProofs.get(spelling);
3261
4028
  if (isTransformScratchInput(spelling, cached.scratchDirectory)) {
3262
4029
  continue;
3263
4030
  }
3264
- // A speculative candidate has no compile-time read to prove. Requiring one
3265
- // would void every generation of every project whose resolution passes over
3266
- // a higher-priority spelling, which is every project with a dependency
3267
- // typed by a declaration file (samchon/ttsc#1245). It is validated instead
3268
- // against the state {@link captureExternalInputSnapshot} recorded for it.
3269
- if (proof === undefined && graph.speculative.has(identity)) {
4031
+ if (predicateConflictSpellings.has(spelling)) {
3270
4032
  continue;
3271
4033
  }
3272
- if (graph.inputProofConflicts.has(identity)) {
4034
+ if (graph.inputProofConflicts.has(spelling)) {
3273
4035
  recordGenerationProofFailure(failures, {
3274
4036
  domain: "graph",
3275
4037
  kind: "proof-conflict",
3276
- detail: graph.inputProofFailures.get(identity),
4038
+ detail: graph.inputProofFailures.get(spelling),
4039
+ path: spelling,
4040
+ });
4041
+ continue;
4042
+ }
4043
+ if (graph.inputProofFailures.has(spelling)) {
4044
+ recordGenerationProofFailure(failures, {
4045
+ domain: "graph",
4046
+ kind: "proof-missing",
4047
+ detail: graph.inputProofFailures.get(spelling),
3277
4048
  path: spelling,
3278
4049
  });
3279
4050
  continue;
3280
4051
  }
4052
+ const observation = graph.inputObservations.get(spelling);
4053
+ if (observation !== undefined && proof !== undefined) {
4054
+ const projection = legacyProjectionOfGraphInputObservation(observation);
4055
+ if (
4056
+ projection.failure !== undefined ||
4057
+ projection.hash !== proof.hash ||
4058
+ !sameHostInputRealpath(
4059
+ projection.realpath,
4060
+ proof.realpath,
4061
+ state.identityContext,
4062
+ )
4063
+ ) {
4064
+ recordGenerationProofFailure(failures, {
4065
+ domain: "graph",
4066
+ kind: "proof-conflict",
4067
+ detail: projection.failure,
4068
+ path: spelling,
4069
+ });
4070
+ }
4071
+ // The rich predicates were already replayed above. Their legacy
4072
+ // projection is an internal producer-consistency check, never a reason
4073
+ // to read the same filesystem input again. A projection that cannot
4074
+ // represent the observation is itself inconsistent with a supplied
4075
+ // legacy proof, rather than permission to trust either representation.
4076
+ continue;
4077
+ }
4078
+ if (
4079
+ graph.speculative.has(spelling) &&
4080
+ predicateSpellings.has(spelling) &&
4081
+ !graph.inputProofFailures.has(spelling)
4082
+ ) {
4083
+ continue;
4084
+ }
4085
+ // A legacy sidecar can report a resolver candidate without a compiler
4086
+ // predicate. Validate it against the generation snapshot instead.
4087
+ if (proof === undefined && graph.speculative.has(spelling)) {
4088
+ continue;
4089
+ }
3281
4090
  if (proof === undefined) {
3282
4091
  recordGenerationProofFailure(failures, {
3283
4092
  domain: "graph",
3284
4093
  kind: "proof-missing",
3285
- detail: graph.inputProofFailures.get(identity),
4094
+ detail: graph.inputProofFailures.get(spelling),
3286
4095
  path: spelling,
3287
4096
  });
3288
4097
  continue;
@@ -3331,6 +4140,15 @@ function matchesRecordedInput(
3331
4140
  ? cached.inputHashes[projectKey]
3332
4141
  : undefined;
3333
4142
  const identity = derivationIdentity(state, input);
4143
+ const observation = cached.externalInputObservations?.[path.resolve(input)];
4144
+ if (observation !== undefined) {
4145
+ return matchesGraphInputObservation(
4146
+ input,
4147
+ observation,
4148
+ filesystem,
4149
+ state.identityContext,
4150
+ );
4151
+ }
3334
4152
  const externalHash = (cached.externalInputHashes ?? {})[identity];
3335
4153
  const externalRealpaths = cached.externalInputRealpaths;
3336
4154
  const graphInput =
@@ -3386,7 +4204,31 @@ export function collectProjectInputHashes(
3386
4204
  filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
3387
4205
  policy?: ITtscProjectMembershipPolicy,
3388
4206
  ): Record<string, string> {
3389
- return collectProjectInputSnapshot(
4207
+ return collectProjectInputHashSnapshot(
4208
+ projectRoot,
4209
+ identities,
4210
+ filesystem,
4211
+ policy,
4212
+ ).hashes;
4213
+ }
4214
+
4215
+ /** One project-walk hash set together with its completeness proof. */
4216
+ export interface TtscProjectInputHashSnapshot {
4217
+ complete: boolean;
4218
+ hashes: Record<string, string>;
4219
+ }
4220
+
4221
+ /**
4222
+ * Hash the project walk and retain whether every attempted directory and file
4223
+ * was observed coherently. Cache-key hosts must reject an incomplete set.
4224
+ */
4225
+ export function collectProjectInputHashSnapshot(
4226
+ projectRoot: string,
4227
+ identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
4228
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
4229
+ policy?: ITtscProjectMembershipPolicy,
4230
+ ): TtscProjectInputHashSnapshot {
4231
+ const snapshot = collectProjectInputSnapshot(
3390
4232
  projectRoot,
3391
4233
  identities,
3392
4234
  filesystem,
@@ -3394,7 +4236,11 @@ export function collectProjectInputHashes(
3394
4236
  {
3395
4237
  policy,
3396
4238
  },
3397
- ).hashes;
4239
+ );
4240
+ return {
4241
+ complete: snapshot.complete && snapshot.directoryComplete,
4242
+ hashes: snapshot.hashes,
4243
+ };
3398
4244
  }
3399
4245
 
3400
4246
  /** Hash project files and snapshot the directory topology in one walk. */
@@ -3453,11 +4299,12 @@ function collectProjectInputSnapshot(
3453
4299
  const before = inputMetadataEvidence(file, filesystem);
3454
4300
  // A file whose signature still equals the one captured around the read
3455
4301
  // that produced the recorded hash carries that content, so the whole
3456
- // project does not have to be re-read to prove one delivery. A signature
3457
- // that was already proven stays proven: its stamp has not moved since the
3458
- // clock provably left its tick.
4302
+ // project does not have to be re-read to prove one delivery. Recheck the
4303
+ // clock ordering as well: rollback can make a formerly safe floor unable
4304
+ // to answer for a new write (samchon/ttsc#1344).
3459
4305
  if (
3460
4306
  before !== undefined &&
4307
+ before.separable &&
3461
4308
  proven !== undefined &&
3462
4309
  proven.signatures[key] === before.signature &&
3463
4310
  Object.prototype.hasOwnProperty.call(proven.hashes, key)
@@ -3673,7 +4520,7 @@ function walkProjectInputs(
3673
4520
 
3674
4521
  /**
3675
4522
  * Return a directory's metadata stamp, used to detect that its membership moved
3676
- * _while_ the walk was enumerating it, and to feed the observed-clock floor.
4523
+ * _while_ the walk was enumerating it.
3677
4524
  *
3678
4525
  * This is the right instrument for that job and the wrong one for comparing two
3679
4526
  * generations: it moves for ignored entries too. {@link walkProjectInputs}
@@ -3685,9 +4532,6 @@ function projectDirectorySignature(
3685
4532
  ): string | undefined {
3686
4533
  try {
3687
4534
  const stats = filesystem.statBigInt(directory);
3688
- // Directory stamps are minted by the same clock as file stamps, so every
3689
- // walk observation also raises the clock floor that separates them.
3690
- observeFilesystemClock(filesystem, stats);
3691
4535
  if (!stats.isDirectory()) {
3692
4536
  return undefined;
3693
4537
  }
@@ -3761,6 +4605,24 @@ function openDirectoryWatch(
3761
4605
  return { close: () => watcher.close() };
3762
4606
  }
3763
4607
 
4608
+ /** Detach and close every watcher, then preserve the first cleanup failure. */
4609
+ function closeDirectoryWatches(watchers: { close: () => void }[]): void {
4610
+ const owned = watchers.splice(0);
4611
+ let failed = false;
4612
+ let failure: unknown;
4613
+ for (const watcher of owned) {
4614
+ try {
4615
+ watcher.close();
4616
+ } catch (error) {
4617
+ if (!failed) {
4618
+ failed = true;
4619
+ failure = error;
4620
+ }
4621
+ }
4622
+ }
4623
+ if (failed) throw failure;
4624
+ }
4625
+
3764
4626
  /** Watch every walked directory for membership changes after generation. */
3765
4627
  async function createProjectMutationTracker(
3766
4628
  directories: readonly TtscProjectDirectorySnapshot[],
@@ -3791,10 +4653,7 @@ async function createProjectMutationTracker(
3791
4653
  return tracker;
3792
4654
  }
3793
4655
  const watchers: { close: () => void }[] = [];
3794
- tracker.close = () => {
3795
- for (const watcher of watchers) watcher.close();
3796
- watchers.length = 0;
3797
- };
4656
+ tracker.close = () => closeDirectoryWatches(watchers);
3798
4657
  for (const directory of directories) {
3799
4658
  try {
3800
4659
  watchers.push(
@@ -3896,10 +4755,7 @@ async function createHostInputMutationTracker(
3896
4755
  return tracker;
3897
4756
  }
3898
4757
  const watchers: { close: () => void }[] = [];
3899
- tracker.close = () => {
3900
- for (const watcher of watchers) watcher.close();
3901
- watchers.length = 0;
3902
- };
4758
+ tracker.close = () => closeDirectoryWatches(watchers);
3903
4759
  for (const location of locations) {
3904
4760
  try {
3905
4761
  const names = new Set(location.names);
@@ -4137,14 +4993,36 @@ async function registerWindowsProjectMutationTracker(
4137
4993
  if (active === undefined) return;
4138
4994
  broker.trackers.delete(id);
4139
4995
  active.ready();
4140
- broker.child.send?.({ id, op: "remove" });
4996
+ let failed = false;
4997
+ let failure: unknown;
4998
+ try {
4999
+ broker.child.send?.({ id, op: "remove" });
5000
+ } catch (error) {
5001
+ failed = true;
5002
+ failure = error;
5003
+ }
4141
5004
  if (broker.trackers.size === 0) {
4142
- broker.child.disconnect?.();
4143
- broker.child.kill();
4144
5005
  if (windowsProjectMutationBroker === broker) {
4145
5006
  windowsProjectMutationBroker = undefined;
4146
5007
  }
5008
+ try {
5009
+ broker.child.disconnect?.();
5010
+ } catch (error) {
5011
+ if (!failed) {
5012
+ failed = true;
5013
+ failure = error;
5014
+ }
5015
+ }
5016
+ try {
5017
+ broker.child.kill();
5018
+ } catch (error) {
5019
+ if (!failed) {
5020
+ failed = true;
5021
+ failure = error;
5022
+ }
5023
+ }
4147
5024
  }
5025
+ if (failed) throw failure;
4148
5026
  };
4149
5027
  broker.child.send?.({
4150
5028
  allEvents,
@@ -4528,10 +5406,251 @@ export function collectExternalInputHashes(
4528
5406
  return hashes;
4529
5407
  }
4530
5408
 
5409
+ /** Capture the stable file predicate used by implicit project discovery. */
5410
+ export function captureWatchInputFileBaseline(
5411
+ file: string,
5412
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
5413
+ ): TtscWatchInputFileBaseline | undefined {
5414
+ const capture = (): TtscWatchInputFileBaseline => {
5415
+ const identities = createHostPathIdentityContext(filesystem);
5416
+ let fileExists = false;
5417
+ try {
5418
+ fileExists = filesystem.stat(file).isFile();
5419
+ } catch {
5420
+ // Project discovery rejects every candidate not proven to be a file.
5421
+ }
5422
+ return {
5423
+ fileExists,
5424
+ identity: pathIdentityKey(file, identities),
5425
+ };
5426
+ };
5427
+ try {
5428
+ const before = capture();
5429
+ const after = capture();
5430
+ return stableStringify(before) === stableStringify(after)
5431
+ ? after
5432
+ : undefined;
5433
+ } catch {
5434
+ return undefined;
5435
+ }
5436
+ }
5437
+
5438
+ /**
5439
+ * Capture one stable main-process baseline that can be compared with any
5440
+ * generation-owned watch-input evidence. Two equal broad observations are
5441
+ * required so a cache key never publishes a torn path state.
5442
+ */
5443
+ export function captureWatchInputBaseline(
5444
+ file: string,
5445
+ filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
5446
+ ): TtscWatchInputBaseline | undefined {
5447
+ const capture = (): TtscWatchInputBaseline => {
5448
+ const identities = createHostPathIdentityContext(filesystem);
5449
+ const stat = compilerStatKind(file, filesystem);
5450
+ return {
5451
+ directoryExists: stat === "directory",
5452
+ fileExists: stat === "file",
5453
+ graphHash: graphInputStateHash(file, filesystem) ?? MISSING_INPUT_STATE,
5454
+ graphReadHash: graphInputReadHash(file, filesystem),
5455
+ hostHash: hostInputStateHash(file, filesystem) ?? MISSING_INPUT_STATE,
5456
+ identity: pathIdentityKey(file, identities),
5457
+ realpath: compilerInputRealpathObservation(file, filesystem),
5458
+ stat,
5459
+ };
5460
+ };
5461
+ try {
5462
+ const before = capture();
5463
+ const after = capture();
5464
+ return stableStringify(before) === stableStringify(after)
5465
+ ? after
5466
+ : undefined;
5467
+ } catch {
5468
+ return undefined;
5469
+ }
5470
+ }
5471
+
5472
+ /** Compare generation evidence with the main process's exact key baseline. */
5473
+ export function watchInputEvidenceMatchesBaseline(
5474
+ evidence: TtscWatchInputEvidence,
5475
+ baseline: TtscWatchInputKeyBaseline,
5476
+ ): boolean {
5477
+ if (
5478
+ !isWatchInputKeyBaseline(baseline) ||
5479
+ evidence.identity !== baseline.identity ||
5480
+ evidence.state === undefined
5481
+ ) {
5482
+ return false;
5483
+ }
5484
+ const broad = broadWatchInputBaseline(baseline);
5485
+ if (evidence.state.codec === "host") {
5486
+ return broad !== undefined && evidence.state.hash === broad.hostHash;
5487
+ }
5488
+ const identities = createHostPathIdentityContext();
5489
+ if (evidence.state.codec === "graph") {
5490
+ return (
5491
+ broad !== undefined &&
5492
+ evidence.state.hash === broad.graphHash &&
5493
+ sameHostInputRealpath(
5494
+ evidence.state.realpath,
5495
+ broad.realpath.ok ? broad.realpath.path : null,
5496
+ identities,
5497
+ )
5498
+ );
5499
+ }
5500
+ const observation = evidence.state.observation;
5501
+ if (
5502
+ observation.fileExists !== undefined &&
5503
+ observation.fileExists !== baseline.fileExists
5504
+ ) {
5505
+ return false;
5506
+ }
5507
+ if (
5508
+ observation.directoryExists !== undefined &&
5509
+ (broad === undefined ||
5510
+ observation.directoryExists !== broad.directoryExists)
5511
+ ) {
5512
+ return false;
5513
+ }
5514
+ if (
5515
+ observation.stat !== undefined &&
5516
+ (broad === undefined || observation.stat !== broad.stat)
5517
+ ) {
5518
+ return false;
5519
+ }
5520
+ if (
5521
+ observation.readFile !== undefined &&
5522
+ (broad === undefined ||
5523
+ (observation.readFile.ok &&
5524
+ observation.readFile.hash !== broad.graphReadHash) ||
5525
+ (!observation.readFile.ok && broad.graphReadHash !== null))
5526
+ ) {
5527
+ return false;
5528
+ }
5529
+ if (observation.realpath !== undefined) {
5530
+ if (broad === undefined) {
5531
+ return false;
5532
+ }
5533
+ if (observation.realpath.ok !== broad.realpath.ok) {
5534
+ return false;
5535
+ }
5536
+ if (
5537
+ observation.realpath.ok &&
5538
+ broad.realpath.ok &&
5539
+ !sameHostInputRealpath(
5540
+ observation.realpath.path,
5541
+ broad.realpath.path,
5542
+ identities,
5543
+ )
5544
+ ) {
5545
+ return false;
5546
+ }
5547
+ }
5548
+ return true;
5549
+ }
5550
+
5551
+ /** Validate the complete narrow or broad shape stored in a key baseline. */
5552
+ export function isWatchInputKeyBaseline(
5553
+ baseline: unknown,
5554
+ ): baseline is TtscWatchInputKeyBaseline {
5555
+ if (!isPlainRecord(baseline)) return false;
5556
+ const keys = Object.keys(baseline).sort();
5557
+ if (
5558
+ typeof baseline.fileExists !== "boolean" ||
5559
+ typeof baseline.identity !== "string" ||
5560
+ !isAbsoluteFilesystemPath(baseline.identity)
5561
+ ) {
5562
+ return false;
5563
+ }
5564
+ if (stableStringify(keys) === stableStringify(["fileExists", "identity"])) {
5565
+ return true;
5566
+ }
5567
+ if (
5568
+ stableStringify(keys) !==
5569
+ stableStringify([
5570
+ "directoryExists",
5571
+ "fileExists",
5572
+ "graphHash",
5573
+ "graphReadHash",
5574
+ "hostHash",
5575
+ "identity",
5576
+ "realpath",
5577
+ "stat",
5578
+ ])
5579
+ ) {
5580
+ return false;
5581
+ }
5582
+ if (
5583
+ typeof baseline.directoryExists !== "boolean" ||
5584
+ !isWatchInputStateHash(baseline.graphHash) ||
5585
+ !(
5586
+ baseline.graphReadHash === null || isContentHash(baseline.graphReadHash)
5587
+ ) ||
5588
+ !isWatchInputStateHash(baseline.hostHash) ||
5589
+ !isWatchInputRealpathBaseline(baseline.realpath) ||
5590
+ (baseline.stat !== "directory" &&
5591
+ baseline.stat !== "file" &&
5592
+ baseline.stat !== "missing")
5593
+ ) {
5594
+ return false;
5595
+ }
5596
+ return (
5597
+ baseline.fileExists === (baseline.stat === "file") &&
5598
+ baseline.directoryExists === (baseline.stat === "directory")
5599
+ );
5600
+ }
5601
+
5602
+ /** Whether an unknown value is one ordinary JSON object. */
5603
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
5604
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
5605
+ return false;
5606
+ }
5607
+ const prototype = Object.getPrototypeOf(value);
5608
+ return prototype === Object.prototype || prototype === null;
5609
+ }
5610
+
5611
+ /** Whether one identity can name an absolute path on either supported syntax. */
5612
+ function isAbsoluteFilesystemPath(value: string): boolean {
5613
+ return path.posix.isAbsolute(value) || path.win32.isAbsolute(value);
5614
+ }
5615
+
5616
+ /** Whether a serialized hash is a content digest. */
5617
+ function isContentHash(value: unknown): value is string {
5618
+ return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
5619
+ }
5620
+
5621
+ /** Whether a baseline state is either a digest or the missing marker. */
5622
+ function isWatchInputStateHash(value: unknown): value is string {
5623
+ return value === MISSING_INPUT_STATE || isContentHash(value);
5624
+ }
5625
+
5626
+ /** Validate the serialized Realpath predicate as an exact discriminated union. */
5627
+ function isWatchInputRealpathBaseline(
5628
+ value: unknown,
5629
+ ): value is TtscWatchInputBaseline["realpath"] {
5630
+ if (!isPlainRecord(value) || typeof value.ok !== "boolean") return false;
5631
+ const keys = Object.keys(value).sort();
5632
+ if (value.ok === false) {
5633
+ return stableStringify(keys) === stableStringify(["ok"]);
5634
+ }
5635
+ return (
5636
+ stableStringify(keys) === stableStringify(["ok", "path"]) &&
5637
+ typeof value.path === "string" &&
5638
+ isAbsoluteFilesystemPath(value.path)
5639
+ );
5640
+ }
5641
+
5642
+ /** Return a broad baseline after the complete entry has been validated. */
5643
+ function broadWatchInputBaseline(
5644
+ baseline: TtscWatchInputKeyBaseline,
5645
+ ): TtscWatchInputBaseline | undefined {
5646
+ return "directoryExists" in baseline ? baseline : undefined;
5647
+ }
5648
+
4531
5649
  /**
4532
5650
  * Re-check a cached mixed graph/dependency input set with its owning codec,
4533
5651
  * reusing the recorded hash of any input whose metadata signature still holds
4534
- * and reporting the signatures this pass captured.
5652
+ * under a freshly minted same-device reference and reporting the signatures
5653
+ * this pass captured.
4535
5654
  *
4536
5655
  * The caller adopts those signatures only once every input is proven unchanged,
4537
5656
  * so a signature never outlives the content comparison that justified it.
@@ -4557,6 +5676,20 @@ function matchesCachedExternalInputs(cached: TtscCachedProjectTransform): {
4557
5676
  Object.keys(cached.externalInputHashes ?? {})) {
4558
5677
  const identity = derivationIdentity(state, file);
4559
5678
  const spelling = path.resolve(file);
5679
+ const observation = cached.externalInputObservations?.[spelling];
5680
+ if (observation !== undefined) {
5681
+ if (
5682
+ !matchesGraphInputObservation(
5683
+ file,
5684
+ observation,
5685
+ filesystem,
5686
+ state.identityContext,
5687
+ )
5688
+ ) {
5689
+ matches = false;
5690
+ }
5691
+ continue;
5692
+ }
4560
5693
  // Reuse the recorded hash of an out-of-walk input whose signature still
4561
5694
  // equals the one captured around the read that proved it. The signature is
4562
5695
  // keyed by this exact spelling, so an alias of the same physical file
@@ -4566,6 +5699,7 @@ function matchesCachedExternalInputs(cached: TtscCachedProjectTransform): {
4566
5699
  before !== undefined &&
4567
5700
  Object.prototype.hasOwnProperty.call(recordedSignatures, spelling) &&
4568
5701
  Object.prototype.hasOwnProperty.call(recordedHashes, identity) &&
5702
+ before.separable &&
4569
5703
  before.signature === recordedSignatures[spelling]
4570
5704
  ) {
4571
5705
  continue;
@@ -4624,6 +5758,14 @@ function selectExternalInputPaths(props: {
4624
5758
  const identities = createHostPathIdentityContext(filesystem);
4625
5759
  const resolutionCandidates = new Set<string>();
4626
5760
  const graph = props.result.graph;
5761
+ const graphState = envelopeDerivation({
5762
+ projectRoot: props.projectRoot,
5763
+ result: props.result,
5764
+ });
5765
+ const graphIndexes = envelopeGraphIndexes(graphState, {
5766
+ projectRoot: props.projectRoot,
5767
+ result: props.result,
5768
+ });
4627
5769
  // Every transform output key names the source file whose transformed text it
4628
5770
  // carries. Keep an out-of-walk source in the external snapshot instead of
4629
5771
  // injecting it into the project-walk key universe (samchon/ttsc#252).
@@ -4635,7 +5777,11 @@ function selectExternalInputPaths(props: {
4635
5777
  members.push(...targets);
4636
5778
  }
4637
5779
  }
4638
- for (const listed of [graph.globals, graph.configs]) {
5780
+ for (const listed of [
5781
+ graph.globals,
5782
+ graph.configs,
5783
+ graph.resolutionInputs,
5784
+ ]) {
4639
5785
  if (Array.isArray(listed)) {
4640
5786
  members.push(...listed);
4641
5787
  }
@@ -4650,7 +5796,12 @@ function selectExternalInputPaths(props: {
4650
5796
  }
4651
5797
  const absolute = path.resolve(props.projectRoot, candidate);
4652
5798
  members.push(candidate);
4653
- resolutionCandidates.add(pathIdentityKey(absolute, identities));
5799
+ resolutionCandidates.add(path.resolve(absolute));
5800
+ }
5801
+ }
5802
+ for (const input of graph.resolutionInputs ?? []) {
5803
+ if (typeof input === "string" && input.length !== 0) {
5804
+ resolutionCandidates.add(path.resolve(props.projectRoot, input));
4654
5805
  }
4655
5806
  }
4656
5807
  }
@@ -4666,9 +5817,7 @@ function selectExternalInputPaths(props: {
4666
5817
  // Plugin discovery inputs deliberately include absent config and
4667
5818
  // resolution probes. A project walk cannot snapshot a path that does
4668
5819
  // not exist yet, even when its spelling lies below projectRoot.
4669
- resolutionCandidates.add(
4670
- pathIdentityKey(path.resolve(props.projectRoot, input), identities),
4671
- );
5820
+ resolutionCandidates.add(path.resolve(props.projectRoot, input));
4672
5821
  }
4673
5822
  }
4674
5823
  }
@@ -4685,8 +5834,11 @@ function selectExternalInputPaths(props: {
4685
5834
  const absolute = path.resolve(props.projectRoot, member);
4686
5835
  const spelling = path.resolve(absolute);
4687
5836
  const identity = pathIdentityKey(absolute, identities);
5837
+ const observation = graphIndexes.inputObservations.get(spelling);
4688
5838
  const missingCandidate =
4689
- resolutionCandidates.has(identity) && !filesystem.exists(absolute);
5839
+ resolutionCandidates.has(spelling) &&
5840
+ (observation?.fileExists === false ||
5841
+ (observation === undefined && !filesystem.exists(absolute)));
4690
5842
  if (
4691
5843
  identity === excluded ||
4692
5844
  isTransformScratchInput(absolute, props.scratchDirectory) ||
@@ -4744,6 +5896,14 @@ function selectNotifiableAbsentInputs(props: {
4744
5896
  return empty;
4745
5897
  }
4746
5898
  const identities = createHostPathIdentityContext(props.filesystem);
5899
+ const graphState = envelopeDerivation({
5900
+ projectRoot: props.projectRoot,
5901
+ result: props.result,
5902
+ });
5903
+ const graphIndexes = envelopeGraphIndexes(graphState, {
5904
+ projectRoot: props.projectRoot,
5905
+ result: props.result,
5906
+ });
4747
5907
  const excluded =
4748
5908
  props.temporaryTsconfig === undefined
4749
5909
  ? undefined
@@ -4757,7 +5917,10 @@ function selectNotifiableAbsentInputs(props: {
4757
5917
  // them. Sharing a set would let one silently answer for the other.
4758
5918
  const seen = new Set<string>();
4759
5919
  const chain = new Set<string>();
4760
- for (const candidates of Object.values(graph.candidates ?? {})) {
5920
+ for (const candidates of [
5921
+ ...Object.values(graph.candidates ?? {}),
5922
+ graph.resolutionInputs ?? [],
5923
+ ]) {
4761
5924
  if (!Array.isArray(candidates)) {
4762
5925
  continue;
4763
5926
  }
@@ -4767,12 +5930,16 @@ function selectNotifiableAbsentInputs(props: {
4767
5930
  }
4768
5931
  const absolute = path.resolve(props.projectRoot, candidate);
4769
5932
  const spelling = path.resolve(absolute);
5933
+ const observation = graphIndexes.inputObservations.get(spelling);
5934
+ const absentAsFile =
5935
+ observation?.fileExists === false ||
5936
+ (observation === undefined && !props.filesystem.exists(absolute));
4770
5937
  if (
4771
5938
  seen.has(spelling) ||
4772
5939
  isTransformScratchInput(absolute, props.scratchDirectory) ||
4773
5940
  (excluded !== undefined &&
4774
5941
  pathIdentityKey(absolute, identities) === excluded) ||
4775
- props.filesystem.exists(absolute)
5942
+ !absentAsFile
4776
5943
  ) {
4777
5944
  continue;
4778
5945
  }
@@ -4882,20 +6049,6 @@ function insideProject(directory: string, projectRoot: string): boolean {
4882
6049
  */
4883
6050
  const NOTIFIABLE_ABSENCE_DIRECTORY_LIMIT = 512;
4884
6051
 
4885
- function isIgnoredProjectDirectory(name: string): boolean {
4886
- // The residue of what used to be a fifteen-name list, kept to the three
4887
- // directories no tsconfig can name and no program can contain: the VCS
4888
- // store, the package manager's tree (TypeScript's own default `exclude`
4889
- // carries it too), and ttsc's own plugin cache. Everything else the old list
4890
- // guessed at, and guessing was wrong in both directions: a bundler writing
4891
- // to an unnamed directory changed project membership with its own output,
4892
- // while a real source directory named `build` or `temp` was dropped from the
4893
- // walk and its new files were never seen (samchon/ttsc#1307). Those are now
4894
- // decided by `ITtscProjectMembershipPolicy`, which reads the configuration
4895
- // that actually knows.
4896
- return name === ".git" || name === ".ttsc" || name === "node_modules";
4897
- }
4898
-
4899
6052
  /**
4900
6053
  * Whether the resolved configuration keeps this directory out of the program.
4901
6054
  *
@@ -5181,6 +6334,8 @@ function selectDeclaredProjectInputKeys(props: {
5181
6334
  for (const input of graph.globals) add(input);
5182
6335
  if (Array.isArray(graph.configs))
5183
6336
  for (const input of graph.configs) add(input);
6337
+ if (Array.isArray(graph.resolutionInputs))
6338
+ for (const input of graph.resolutionInputs) add(input);
5184
6339
  for (const [source, candidates] of Object.entries(graph.candidates ?? {})) {
5185
6340
  add(source);
5186
6341
  if (Array.isArray(candidates)) for (const entry of candidates) add(entry);
@@ -5478,6 +6633,10 @@ function createUnstableGenerationError(
5478
6633
  lines.push(
5479
6634
  " Stop writes to the listed inputs before compilation, or fix the producer that omitted or contradicted the listed proof.",
5480
6635
  );
6636
+ // The failed snapshot remains useful as immutable retry evidence, but it can
6637
+ // never serve a module. Release every live resource before its terminal
6638
+ // verdict is retained in the cache.
6639
+ disposeCachedTransform(validation.cached);
5481
6640
  return new TtscUnstableGenerationError(lines.join("\n"), validation);
5482
6641
  }
5483
6642
 
@@ -5504,9 +6663,10 @@ async function transformProject(props: {
5504
6663
  for (let attempt = 0; attempt < TRANSFORM_GENERATION_ATTEMPTS; attempt += 1) {
5505
6664
  const cached = await captureTransformGeneration(props);
5506
6665
  if (
5507
- !props.trackProjectMembership ||
5508
- cached.result.type !== "success" ||
5509
- cached.projectSnapshotComplete === true
6666
+ cached.configStateComplete !== false &&
6667
+ (!props.trackProjectMembership ||
6668
+ cached.result.type !== "success" ||
6669
+ cached.projectSnapshotComplete === true)
5510
6670
  ) {
5511
6671
  return cached;
5512
6672
  }
@@ -5552,23 +6712,54 @@ async function captureTransformGeneration(props: {
5552
6712
  projectRoot,
5553
6713
  props.filesystem,
5554
6714
  );
6715
+ let clockReferenceDirectory: string | undefined;
6716
+ let retainClockReferenceDirectory = false;
5555
6717
  let tracker: TtscProjectMutationTracker | undefined;
5556
6718
  let retainTracker = false;
5557
6719
  let hostInputTracker: TtscProjectMutationTracker | undefined;
5558
6720
  let candidateTracker: TtscProjectMutationTracker | undefined;
5559
6721
  let retainHostInputTracker = false;
5560
6722
  let retainCandidateTracker = false;
6723
+ let captured: TtscCachedProjectTransform | undefined;
5561
6724
  try {
5562
- const configured = createTransformTsconfig(props, scratchDirectory);
6725
+ if (props.trackProjectMembership) {
6726
+ try {
6727
+ clockReferenceDirectory = createTransformScratchDirectory(
6728
+ projectRoot,
6729
+ props.filesystem,
6730
+ );
6731
+ } catch {
6732
+ // A retained probe is an optimization. The live compiler scratch can
6733
+ // still authorize capture, and later validations will compare bytes.
6734
+ }
6735
+ }
6736
+ const materializesConfig =
6737
+ Object.keys(props.compilerOptions).length !== 0 ||
6738
+ Object.keys(props.aliasPaths).length !== 0;
6739
+ const tsconfigState = readTransformTsconfigState(
6740
+ props.tsconfig,
6741
+ materializesConfig,
6742
+ );
6743
+ const configured = createTransformTsconfig(
6744
+ props,
6745
+ scratchDirectory,
6746
+ tsconfigState,
6747
+ );
5563
6748
  const temporaryTsconfig =
5564
6749
  configured.path === props.tsconfig ? undefined : configured.path;
6750
+ const compilerEnvironment = transformScratchEnvironment(scratchDirectory);
6751
+ if (temporaryTsconfig === undefined) {
6752
+ delete compilerEnvironment[TTSC_SEMANTIC_CONFIG_PATH];
6753
+ } else {
6754
+ compilerEnvironment[TTSC_SEMANTIC_CONFIG_PATH] = props.tsconfig;
6755
+ }
5565
6756
  const identities = createHostPathIdentityContext(props.filesystem);
5566
6757
  // Read from the project's own tsconfig rather than the generated one: a
5567
6758
  // relative `outDir` is anchored at the config that declares it, and the
5568
6759
  // generated config lives in a system temp directory. The caller's
5569
6760
  // compiler-options overlay still wins, since it wins for the compile too.
5570
6761
  const membershipPolicy = mergeMembershipPolicyOverlay(
5571
- readProjectMembershipPolicy(props.tsconfig),
6762
+ tsconfigState.membershipPolicy,
5572
6763
  props.compilerOptions,
5573
6764
  projectRoot,
5574
6765
  );
@@ -5599,14 +6790,21 @@ async function captureTransformGeneration(props: {
5599
6790
  plugins: props.plugins,
5600
6791
  projectRoot,
5601
6792
  tsconfig: configured.path,
5602
- env: transformScratchEnvironment(scratchDirectory),
6793
+ env: compilerEnvironment,
5603
6794
  }).transform(),
5604
6795
  );
5605
6796
  TRANSFORM_RESULT_FILESYSTEM.set(result, props.filesystem);
6797
+ const configStable =
6798
+ tsconfigState.signature === undefined ||
6799
+ tsconfigState.signature ===
6800
+ readTransformTsconfigState(props.tsconfig, true).signature;
5606
6801
  // Mint the generation's clock reference after the compile and before any
5607
6802
  // signature-recording read below, so every input written before the
5608
6803
  // compile sits in a provably finished tick when its signature is captured.
5609
- mintFilesystemClockReference(scratchDirectory, props.filesystem);
6804
+ refreshFilesystemClockReference(
6805
+ clockReferenceDirectory ?? scratchDirectory,
6806
+ props.filesystem,
6807
+ );
5610
6808
  const persistentHostInputs = selectPersistentHostInputs({
5611
6809
  filesystem: props.filesystem,
5612
6810
  projectRoot,
@@ -5684,6 +6882,7 @@ async function captureTransformGeneration(props: {
5684
6882
  scratchDirectory,
5685
6883
  });
5686
6884
  const walkStable =
6885
+ configStable &&
5687
6886
  walkSnapshotComplete(before, declaredInputs) &&
5688
6887
  walkSnapshotComplete(inputSnapshot, declaredInputs) &&
5689
6888
  sameHashes(before.hashes, inputSnapshot.hashes, declaredInputs) &&
@@ -5734,6 +6933,7 @@ async function captureTransformGeneration(props: {
5734
6933
  externalInputHashes: {},
5735
6934
  externalInputRealpaths: {},
5736
6935
  externalInputPaths,
6936
+ configStateComplete: configStable,
5737
6937
  inputHashes: inputSnapshot.hashes,
5738
6938
  inputSignatures: inputSnapshot.provenSignatures,
5739
6939
  membershipPolicy,
@@ -5759,6 +6959,7 @@ async function captureTransformGeneration(props: {
5759
6959
  externalInputPaths,
5760
6960
  );
5761
6961
  cached.externalInputHashes = externalInputSnapshot.hashes;
6962
+ cached.externalInputObservations = externalInputSnapshot.observations;
5762
6963
  cached.externalInputRealpaths = externalInputSnapshot.realpaths;
5763
6964
  cached.externalInputSignatures = externalInputSnapshot.signatures;
5764
6965
  // Evaluate every half, rather than short-circuiting, so a generation that
@@ -5766,6 +6967,13 @@ async function captureTransformGeneration(props: {
5766
6967
  // only on the failing path, where the alternative is recompiling the whole
5767
6968
  // project for every remaining module.
5768
6969
  const failures = createGenerationProofFailures();
6970
+ if (!configStable) {
6971
+ recordGenerationProofFailure(failures, {
6972
+ domain: "project",
6973
+ kind: "config-state-changed",
6974
+ path: props.tsconfig,
6975
+ });
6976
+ }
5769
6977
  if (!walkStable) {
5770
6978
  recordProjectSnapshotFailures(failures, {
5771
6979
  before,
@@ -5834,28 +7042,80 @@ async function captureTransformGeneration(props: {
5834
7042
  retainTracker = notifying && tracker !== undefined;
5835
7043
  retainHostInputTracker = notifying && hostInputTracker !== undefined;
5836
7044
  retainCandidateTracker = notifying && candidateTracker !== undefined;
5837
- return cached;
7045
+ if (clockReferenceDirectory !== undefined) {
7046
+ retainClockReferenceDirectory = true;
7047
+ }
7048
+ captured = cached;
5838
7049
  } finally {
7050
+ let cleanupFailed = false;
7051
+ let cleanupFailure: unknown;
5839
7052
  try {
5840
- if (!retainTracker && tracker !== undefined) {
5841
- tracker.close();
5842
- }
5843
- } finally {
5844
7053
  try {
5845
- if (!retainHostInputTracker && hostInputTracker !== undefined) {
5846
- hostInputTracker.close();
7054
+ if (!retainTracker && tracker !== undefined) {
7055
+ tracker.close();
5847
7056
  }
5848
7057
  } finally {
5849
7058
  try {
5850
- if (!retainCandidateTracker && candidateTracker !== undefined) {
5851
- candidateTracker.close();
7059
+ if (!retainHostInputTracker && hostInputTracker !== undefined) {
7060
+ hostInputTracker.close();
5852
7061
  }
5853
7062
  } finally {
5854
- fs.rmSync(scratchDirectory, { force: true, recursive: true });
7063
+ try {
7064
+ if (!retainCandidateTracker && candidateTracker !== undefined) {
7065
+ candidateTracker.close();
7066
+ }
7067
+ } finally {
7068
+ try {
7069
+ fs.rmSync(scratchDirectory, { force: true, recursive: true });
7070
+ } finally {
7071
+ if (
7072
+ !retainClockReferenceDirectory &&
7073
+ clockReferenceDirectory !== undefined
7074
+ ) {
7075
+ disposeFilesystemClockReference(clockReferenceDirectory);
7076
+ }
7077
+ }
7078
+ }
7079
+ }
7080
+ }
7081
+ } catch (error) {
7082
+ cleanupFailed = true;
7083
+ cleanupFailure = error;
7084
+ }
7085
+ if (cleanupFailed) {
7086
+ // The generation never leaves this function when local cleanup fails.
7087
+ // Close everything that was waiting to transfer, without letting a
7088
+ // secondary teardown error replace the first failure.
7089
+ for (const retained of [
7090
+ retainTracker ? tracker : undefined,
7091
+ retainHostInputTracker ? hostInputTracker : undefined,
7092
+ retainCandidateTracker ? candidateTracker : undefined,
7093
+ ]) {
7094
+ try {
7095
+ retained?.close();
7096
+ } catch {
7097
+ // Continue releasing the other generation-owned resources.
5855
7098
  }
5856
7099
  }
7100
+ if (
7101
+ retainClockReferenceDirectory &&
7102
+ clockReferenceDirectory !== undefined
7103
+ ) {
7104
+ disposeFilesystemClockReference(clockReferenceDirectory);
7105
+ }
7106
+ throw cleanupFailure;
5857
7107
  }
5858
7108
  }
7109
+ if (captured === undefined) {
7110
+ throw new Error("ttsc: transform generation capture produced no result");
7111
+ }
7112
+ if (clockReferenceDirectory !== undefined) {
7113
+ TRANSFORM_CLOCK_REFERENCE_DIRECTORIES.set(
7114
+ captured,
7115
+ clockReferenceDirectory,
7116
+ );
7117
+ }
7118
+ return captured;
5859
7119
  }
5860
7120
 
5861
7121
  /** Exclude disposed transform scratch from live host-input tracking. */
@@ -5884,6 +7144,50 @@ function selectPersistentHostInputs(props: {
5884
7144
  });
5885
7145
  }
5886
7146
 
7147
+ interface ITransformTsconfigState {
7148
+ effectivePaths: Record<string, string[]>;
7149
+ membershipPolicy: ITtscProjectMembershipPolicy;
7150
+ signature?: string;
7151
+ templateCompilerOptions: Record<string, unknown>;
7152
+ templateFileSpecs: Record<string, unknown>;
7153
+ }
7154
+
7155
+ /** Read every wrapper-dependent view and bind it to one config-chain state. */
7156
+ function readTransformTsconfigState(
7157
+ tsconfig: string,
7158
+ materializesConfig: boolean,
7159
+ ): ITransformTsconfigState {
7160
+ const membershipPolicy = readProjectMembershipPolicy(tsconfig);
7161
+ if (!materializesConfig) {
7162
+ return {
7163
+ effectivePaths: {},
7164
+ membershipPolicy,
7165
+ templateCompilerOptions: {},
7166
+ templateFileSpecs: {},
7167
+ };
7168
+ }
7169
+ const effectivePaths = readEffectiveTsconfigPaths(tsconfig);
7170
+ const templateCompilerOptions =
7171
+ readEffectiveTsconfigTemplateCompilerOptions(tsconfig);
7172
+ const templateFileSpecs = readEffectiveTsconfigTemplateFileSpecs(tsconfig);
7173
+ const sources = readTsconfigSourceSnapshot(tsconfig);
7174
+ return {
7175
+ effectivePaths,
7176
+ membershipPolicy,
7177
+ signature: hashText(
7178
+ JSON.stringify({
7179
+ effectivePaths,
7180
+ membershipPolicy,
7181
+ sources,
7182
+ templateCompilerOptions,
7183
+ templateFileSpecs,
7184
+ }),
7185
+ ),
7186
+ templateCompilerOptions,
7187
+ templateFileSpecs,
7188
+ };
7189
+ }
7190
+
5887
7191
  function createTransformTsconfig(
5888
7192
  props: {
5889
7193
  aliasPaths: Record<string, string[]>;
@@ -5891,24 +7195,34 @@ function createTransformTsconfig(
5891
7195
  tsconfig: string;
5892
7196
  },
5893
7197
  scratchDirectory: string,
7198
+ state: ITransformTsconfigState,
5894
7199
  ): { path: string } {
5895
- const compilerOptions = normalizeCompilerOptionsForGeneratedTsconfig(
7200
+ const overlay = normalizeCompilerOptionsForGeneratedTsconfig(
5896
7201
  {
5897
7202
  ...props.compilerOptions,
5898
- ...createAliasCompilerOptions(props),
7203
+ ...createAliasCompilerOptions(props, state.effectivePaths),
5899
7204
  },
5900
7205
  path.dirname(props.tsconfig),
5901
7206
  );
5902
- if (Object.keys(compilerOptions).length === 0) {
7207
+ if (Object.keys(overlay).length === 0) {
5903
7208
  return { path: props.tsconfig };
5904
7209
  }
5905
7210
 
7211
+ // A `${configDir}` value survives every `extends` hop and binds only at the
7212
+ // final consumer. The scratch wrapper would therefore move it out of the
7213
+ // project even for an unrelated overlay. Re-state only those inherited
7214
+ // values as absolute paths so the wrapper remains semantically transparent.
7215
+ const compilerOptions = {
7216
+ ...state.templateCompilerOptions,
7217
+ ...overlay,
7218
+ };
5906
7219
  const file = path.join(scratchDirectory, "tsconfig.json");
5907
7220
  fs.writeFileSync(
5908
7221
  file,
5909
7222
  JSON.stringify(
5910
7223
  {
5911
7224
  extends: normalizePath(props.tsconfig),
7225
+ ...state.templateFileSpecs,
5912
7226
  compilerOptions,
5913
7227
  },
5914
7228
  null,
@@ -6067,16 +7381,18 @@ function normalizeCompilerOptionsForGeneratedTsconfig(
6067
7381
  ): Record<string, unknown> {
6068
7382
  const output = { ...compilerOptions };
6069
7383
  // Scalar path fields: resolve each against the original tsconfig directory.
6070
- for (const key of ["declarationDir", "outDir", "rootDir"]) {
7384
+ for (const key of CONFIG_DIR_TEMPLATE_SCALAR_OPTIONS) {
6071
7385
  if (typeof output[key] === "string") {
6072
- output[key] = path.resolve(tsconfigDir, output[key]);
7386
+ output[key] = resolveConfigDirTemplatePath(tsconfigDir, output[key]);
6073
7387
  }
6074
7388
  }
6075
7389
  // Array path fields: resolve each element individually.
6076
- for (const key of ["rootDirs", "typeRoots"]) {
7390
+ for (const key of CONFIG_DIR_TEMPLATE_LIST_OPTIONS) {
6077
7391
  if (Array.isArray(output[key])) {
6078
7392
  output[key] = output[key].map((entry) =>
6079
- typeof entry === "string" ? path.resolve(tsconfigDir, entry) : entry,
7393
+ typeof entry === "string"
7394
+ ? resolveConfigDirTemplatePath(tsconfigDir, entry)
7395
+ : entry,
6080
7396
  );
6081
7397
  }
6082
7398
  }
@@ -6136,17 +7452,20 @@ function normalizePluginConfigForGeneratedTsconfig(
6136
7452
  * No `baseUrl` is emitted: TypeScript-Go removed the option (TS5102), and all
6137
7453
  * targets are absolute so none is needed.
6138
7454
  */
6139
- function createAliasCompilerOptions(props: {
6140
- aliasPaths: Record<string, string[]>;
6141
- compilerOptions: Record<string, unknown>;
6142
- tsconfig: string;
6143
- }): Record<string, unknown> {
7455
+ function createAliasCompilerOptions(
7456
+ props: {
7457
+ aliasPaths: Record<string, string[]>;
7458
+ compilerOptions: Record<string, unknown>;
7459
+ tsconfig: string;
7460
+ },
7461
+ effectivePaths: Record<string, string[]>,
7462
+ ): Record<string, unknown> {
6144
7463
  if (Object.keys(props.aliasPaths).length === 0) {
6145
7464
  return {};
6146
7465
  }
6147
7466
  return {
6148
7467
  paths: {
6149
- ...readEffectiveTsconfigPaths(props.tsconfig),
7468
+ ...effectivePaths,
6150
7469
  ...readPaths(props.compilerOptions.paths),
6151
7470
  ...props.aliasPaths,
6152
7471
  },
@@ -6531,9 +7850,9 @@ function stripTerminalEscapes(text: string): string {
6531
7850
  *
6532
7851
  * If `tsconfig` is supplied it is returned as-is (absolute) or resolved from
6533
7852
  * `process.cwd()` (relative). Otherwise the function walks ancestor directories
6534
- * starting at `file`'s directory, returning the first `tsconfig.json` found.
6535
- * Falls back to `<cwd>/tsconfig.json` when no ancestor contains one; the
6536
- * compiler will error if that file does not exist, which is the correct
7853
+ * starting at `file`'s directory, returning the first `tsconfig.json` proven to
7854
+ * be a file. Falls back to `<cwd>/tsconfig.json` when no ancestor contains one;
7855
+ * the compiler will error if that file does not exist, which is the correct
6537
7856
  * behavior for a mis-configured project.
6538
7857
  */
6539
7858
  function resolveTsconfig(
@@ -6547,18 +7866,9 @@ function resolveTsconfig(
6547
7866
  : path.resolve(process.cwd(), tsconfig);
6548
7867
  }
6549
7868
 
6550
- let current = path.dirname(file);
6551
- while (true) {
6552
- const candidate = path.join(current, "tsconfig.json");
6553
- if (filesystem.exists(candidate)) {
6554
- return candidate;
6555
- }
6556
- const parent = path.dirname(current);
6557
- // Reached filesystem root, stop walking.
6558
- if (parent === current) {
6559
- break;
6560
- }
6561
- current = parent;
7869
+ const discovered = findNearestProjectTsconfig(path.dirname(file), filesystem);
7870
+ if (discovered !== undefined) {
7871
+ return discovered;
6562
7872
  }
6563
7873
  return path.resolve(process.cwd(), "tsconfig.json");
6564
7874
  }