@ttsc/unplugin 0.27.0 → 0.28.1

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.
@@ -52,6 +52,7 @@ function createTtscTransformCache(operations = {}) {
52
52
  stat: operations.stat ?? DEFAULT_FILESYSTEM_OPERATIONS.stat,
53
53
  statBigInt: operations.statBigInt ?? DEFAULT_FILESYSTEM_OPERATIONS.statBigInt,
54
54
  platform: operations.platform,
55
+ watch: operations.watch,
55
56
  });
56
57
  return cache;
57
58
  }
@@ -175,12 +176,7 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
175
176
  projectRoot: cached.projectRoot,
176
177
  result: cached.result,
177
178
  });
178
- notifyWatchInputs(hooks, {
179
- file,
180
- projectRoot: cached.projectRoot,
181
- result: cached.result,
182
- temporaryTsconfig: cached.temporaryTsconfig,
183
- });
179
+ notifyWatchInputs(hooks, cached, file);
184
180
  markCachedSourceServed(cached, file);
185
181
  return createTransformResult(source, code);
186
182
  }
@@ -211,14 +207,14 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
211
207
  if (cache !== undefined && cache.get(key) !== generation) {
212
208
  continue;
213
209
  }
214
- const { projectRoot, result, temporaryTsconfig } = cached;
210
+ const { projectRoot, result } = cached;
215
211
  reportSuccessDiagnostics(result);
216
212
  const code = selectOrEvict(cache, key, generation, {
217
213
  file,
218
214
  projectRoot,
219
215
  result,
220
216
  });
221
- notifyWatchInputs(hooks, { file, projectRoot, result, temporaryTsconfig });
217
+ notifyWatchInputs(hooks, cached, file);
222
218
  markCachedSourceServed(cached, file);
223
219
  if (isVolatileFile(envelopeDerivation(cached), { file, projectRoot, result })) {
224
220
  hooks?.markVolatile?.();
@@ -276,9 +272,11 @@ function disposeCachedTransform(cached) {
276
272
  const trackers = [
277
273
  cached.projectMutationTracker,
278
274
  cached.hostInputMutationTracker,
275
+ cached.candidateMutationTracker,
279
276
  ];
280
277
  cached.projectMutationTracker = undefined;
281
278
  cached.hostInputMutationTracker = undefined;
279
+ cached.candidateMutationTracker = undefined;
282
280
  for (const tracker of trackers)
283
281
  tracker?.close();
284
282
  }
@@ -318,6 +316,7 @@ function envelopeGraphIndexes(state, props) {
318
316
  globals: [],
319
317
  configs: [],
320
318
  members: new Set(),
319
+ speculative: new Set(),
321
320
  inputProofs: new Map(),
322
321
  inputProofConflicts: new Set(),
323
322
  };
@@ -346,20 +345,30 @@ function envelopeGraphIndexes(state, props) {
346
345
  for (const input of [...built.globals, ...built.configs]) {
347
346
  built.members.add(derivationIdentity(state, input));
348
347
  }
349
- for (const [source, candidates] of Object.entries(graph.candidates ?? {})) {
350
- if (!Array.isArray(candidates)) {
351
- continue;
352
- }
353
- const sourceIdentity = derivationIdentity(state, path.resolve(props.projectRoot, source));
354
- built.members.add(sourceIdentity);
348
+ const candidateEntries = Object.entries(graph.candidates ?? {}).filter((entry) => Array.isArray(entry[1]));
349
+ // Every candidate source is an importing file the compiler read, so fold
350
+ // the sources in before classifying any candidate. Otherwise one entry's
351
+ // candidate could be classified speculative before a later entry proves
352
+ // the same path is a realized source.
353
+ for (const [source] of candidateEntries) {
354
+ built.members.add(derivationIdentity(state, path.resolve(props.projectRoot, source)));
355
+ }
356
+ const realized = new Set(built.members);
357
+ for (const [source, candidates] of candidateEntries) {
355
358
  built.candidates.push({
356
- source: sourceIdentity,
359
+ source: derivationIdentity(state, path.resolve(props.projectRoot, source)),
357
360
  files: selectListedFiles(props.projectRoot, candidates),
358
361
  });
359
362
  for (const candidate of candidates) {
360
363
  if (typeof candidate !== "string" || candidate.length === 0)
361
364
  continue;
362
- built.members.add(derivationIdentity(state, path.resolve(props.projectRoot, candidate)));
365
+ const identity = derivationIdentity(state, path.resolve(props.projectRoot, candidate));
366
+ // Edges, globals, configs, and every candidate source are folded in
367
+ // above, so a path absent from that set is one the envelope reported
368
+ // only as a candidate.
369
+ if (!realized.has(identity))
370
+ built.speculative.add(identity);
371
+ built.members.add(identity);
363
372
  }
364
373
  }
365
374
  for (const [input, hash] of Object.entries(graph.inputHashes ?? {})) {
@@ -446,13 +455,29 @@ function collectDeclaredIdentities(state, projectRoot, listed) {
446
455
  * watches the module it transforms), and so is the disposed temp-dir tsconfig
447
456
  * (see {@link TtscCachedProjectTransform.temporaryTsconfig}).
448
457
  */
449
- function notifyWatchInputs(hooks, props) {
458
+ function notifyWatchInputs(hooks, cached, file) {
450
459
  const addWatchFile = hooks?.addWatchFile;
451
460
  if (addWatchFile === undefined) {
452
461
  return;
453
462
  }
454
- for (const input of selectWatchInputs(props)) {
455
- addWatchFile(input);
463
+ const state = envelopeDerivation(cached);
464
+ const external = cached.externalInputHashes ?? {};
465
+ for (const input of selectWatchInputs({
466
+ file,
467
+ projectRoot: cached.projectRoot,
468
+ result: cached.result,
469
+ temporaryTsconfig: cached.temporaryTsconfig,
470
+ })) {
471
+ // Hand the adapter the identity this generation already resolved and the
472
+ // existence state it already recorded. Both are memoized per generation,
473
+ // while an adapter deriving them itself pays a `realpath`, a directory
474
+ // listing, and an `existsSync` per input on every delivery of every module
475
+ // (samchon/ttsc#1246).
476
+ const identity = derivationIdentity(state, input);
477
+ addWatchFile(input, {
478
+ identity,
479
+ missing: external[identity] === MISSING_INPUT_STATE,
480
+ });
456
481
  }
457
482
  }
458
483
  /**
@@ -819,7 +844,13 @@ function matchesCachedSource(cached, file, source, buildScoped) {
819
844
  cached.projectDirectories !== undefined &&
820
845
  cached.projectMutationTracker !== undefined &&
821
846
  cached.hostInputMutationTracker !== undefined) {
822
- return matchesNarrowPersistentInputs(cached, file);
847
+ const narrow = matchesNarrowPersistentInputs(cached, file);
848
+ if (narrow !== undefined) {
849
+ return narrow;
850
+ }
851
+ // Notifications stopped proving membership after this generation was
852
+ // produced. Losing the proof is not evidence of a change, so fall through
853
+ // to the snapshot the entry still carries.
823
854
  }
824
855
  return matchesCompleteInputSnapshot(cached, currentKey, source);
825
856
  }
@@ -828,21 +859,27 @@ function matchesCachedSource(cached, file, source, buildScoped) {
828
859
  * affect that file. Project membership is validated once per event-loop turn,
829
860
  * so sibling module deliveries share one directory-metadata pass instead of
830
861
  * multiplying it by module count.
862
+ *
863
+ * Returns `undefined` when this narrow proof is unavailable — live
864
+ * notifications can no longer prove membership, or the generation carries no
865
+ * universal-input manifest. That is the absence of a proof, not evidence of a
866
+ * change, so the caller falls back to complete-snapshot validation instead of
867
+ * discarding the generation. A reported membership event, a changed universal
868
+ * input, or a changed derived input is evidence, and returns `false`.
831
869
  */
832
870
  function matchesNarrowPersistentInputs(cached, file) {
833
- if (!matchesProjectMembership(cached)) {
871
+ if (reportsMembershipChange(cached)) {
834
872
  return false;
835
873
  }
836
- const hostTracker = cached.hostInputMutationTracker;
837
- if (hostTracker === undefined ||
838
- hostTracker.failed ||
839
- hostTracker.membershipChanged) {
840
- return false;
874
+ if (!notificationsProveMembership(cached)) {
875
+ return undefined;
841
876
  }
842
877
  const state = envelopeDerivation(cached);
843
- const hostValidation = state.hostInputValidation;
844
- if (hostValidation === undefined ||
845
- !matchesUniversalHostInputs(cached, hostValidation)) {
878
+ const hostValidation = cached.hostInputValidation;
879
+ if (hostValidation === undefined) {
880
+ return undefined;
881
+ }
882
+ if (!matchesUniversalHostInputs(cached, hostValidation)) {
846
883
  return false;
847
884
  }
848
885
  const inputs = selectWatchInputs({
@@ -852,15 +889,123 @@ function matchesNarrowPersistentInputs(cached, file) {
852
889
  temporaryTsconfig: cached.temporaryTsconfig,
853
890
  });
854
891
  for (const input of inputs) {
855
- if (hostValidation.identities.has(derivationIdentity(state, input))) {
892
+ // Skip by spelling, not identity: the manifest proved this exact path, and
893
+ // an alias of the same physical file is a different input whose own
894
+ // retarget nothing else would see.
895
+ if (hostValidation.covered.has(path.resolve(input))) {
856
896
  continue;
857
897
  }
858
- if (!matchesRecordedInput(cached, input)) {
898
+ if (!matchesProvenInput(cached, state, input)) {
859
899
  return false;
860
900
  }
861
901
  }
862
902
  return true;
863
903
  }
904
+ /**
905
+ * Validate one derived input against the generation, skipping the content read
906
+ * while the recorded metadata signature still holds.
907
+ *
908
+ * Sibling deliveries of one generation share most of their derived inputs, and
909
+ * `graph.globals` is shared by every one of them, so re-reading and re-hashing
910
+ * the whole derived set per delivery multiplies one generation's proven bytes
911
+ * by the module count. The derived set is proven the same way the universal
912
+ * descriptor inputs are ({@link matchesUniversalHostInputs}), under the same
913
+ * rules: an unchanged signature stands in for the content comparison, and any
914
+ * signature change falls back to the full comparison. A signature is recorded
915
+ * only around a read nothing raced, only for a recorded state that came from
916
+ * reading the input rather than from failing to, and only while the observed
917
+ * filesystem's own clock has provably left the stamp's tick
918
+ * ({@link stampSeparable}), so a same-length rewrite inside that tick cannot
919
+ * hide behind an unchanged signature.
920
+ *
921
+ * The signature carries the physical identity of both the lexical path and its
922
+ * link target ({@link inputMetadataSignature}), so retargeting a symlink or
923
+ * junction moves it and the skipped realpath comparison cannot be evaded.
924
+ */
925
+ function matchesProvenInput(cached, state, input) {
926
+ const slot = inputSignatureSlot(cached, state, input);
927
+ if (slot === undefined) {
928
+ return matchesRecordedInput(cached, input);
929
+ }
930
+ if (slot.recorded === MISSING_INPUT_STATE && notifiesAbsence(cached, input)) {
931
+ // The generation's watcher holds this exact name, and the caller already
932
+ // established that neither tracker failed and neither reported a change.
933
+ // The path is therefore still absent, proven by the same channel that
934
+ // proves project membership, and probing it again would only repeat what
935
+ // the notification already answered.
936
+ return true;
937
+ }
938
+ const filesystem = resultFilesystem(cached.result);
939
+ const before = inputMetadataEvidence(input, filesystem);
940
+ if (before !== undefined && slot.signatures[slot.key] === before.signature) {
941
+ return true;
942
+ }
943
+ if (!matchesRecordedInput(cached, input)) {
944
+ return false;
945
+ }
946
+ // A recorded `missing` state is the one comparison that succeeds without
947
+ // reading anything: an unreadable path still reports `missing`, so its
948
+ // metadata can hold still while the bytes behind it appear. Only content a
949
+ // read produced may be stood for.
950
+ const after = slot.recorded === MISSING_INPUT_STATE
951
+ ? undefined
952
+ : inputMetadataSignature(input, filesystem);
953
+ if (after !== undefined && before?.signature === after && before.separable) {
954
+ slot.signatures[slot.key] = after;
955
+ }
956
+ else {
957
+ delete slot.signatures[slot.key];
958
+ }
959
+ return true;
960
+ }
961
+ /**
962
+ * Report whether the generation's live watcher would announce a creation at
963
+ * this absent input's exact spelling.
964
+ *
965
+ * Losing the watcher is not evidence of anything, so a failed tracker sends the
966
+ * input back to being probed by hand, exactly as a failed tracker already sends
967
+ * the whole generation back to complete-snapshot validation.
968
+ */
969
+ function notifiesAbsence(cached, input) {
970
+ const tracker = cached.candidateMutationTracker;
971
+ return (tracker !== undefined &&
972
+ !tracker.failed &&
973
+ tracker.covered?.has(path.resolve(input)) === true);
974
+ }
975
+ /**
976
+ * Locate the signature manifest that owns one recorded input, mirroring
977
+ * {@link matchesRecordedInput}'s own preference for the out-of-walk spelling's
978
+ * snapshot over the walked project's.
979
+ *
980
+ * The manifest is returned whether or not it currently holds a signature for
981
+ * the input, so a content comparison that succeeds can record one. Without
982
+ * that, an input whose capture-time metadata was too recent to prove anything
983
+ * would keep its content read for the whole life of the generation, since
984
+ * nothing else ever revisits it. Returns `undefined` only for an input the
985
+ * generation recorded no hash for, which no signature could stand for.
986
+ */
987
+ function inputSignatureSlot(cached, state, input) {
988
+ const identity = derivationIdentity(state, input);
989
+ const external = cached.externalInputHashes ?? {};
990
+ if (Object.prototype.hasOwnProperty.call(external, identity)) {
991
+ // The recorded hash is identity-keyed because aliases of one physical file
992
+ // share its content; the signature is spelling-keyed because they do not
993
+ // share its metadata.
994
+ return {
995
+ key: path.resolve(input),
996
+ recorded: external[identity],
997
+ signatures: (cached.externalInputSignatures ??= {}),
998
+ };
999
+ }
1000
+ const projectKey = toProjectKey(cached.projectRoot, input, state.identityContext);
1001
+ return Object.prototype.hasOwnProperty.call(cached.inputHashes, projectKey)
1002
+ ? {
1003
+ key: projectKey,
1004
+ recorded: cached.inputHashes[projectKey],
1005
+ signatures: (cached.inputSignatures ??= {}),
1006
+ }
1007
+ : undefined;
1008
+ }
864
1009
  /**
865
1010
  * Validate universal descriptor/config inputs without re-reading them for every
866
1011
  * module. Existing paths use the same nanosecond metadata manifest that guards
@@ -868,10 +1013,24 @@ function matchesNarrowPersistentInputs(cached, file) {
868
1013
  * existing directory and checked through one exact membership listing.
869
1014
  */
870
1015
  function matchesUniversalHostInputs(cached, validation) {
1016
+ return (matchesUniversalHostInputEntries(cached, validation) &&
1017
+ matchesUniversalHostInputProbes(cached, validation));
1018
+ }
1019
+ /**
1020
+ * Validate the universal inputs that exist, by metadata first and content only
1021
+ * when that moved.
1022
+ *
1023
+ * Every rejection here is evidence of a change — a vanished path, a moved
1024
+ * physical target, a strict blocker's metadata, differing content — so this
1025
+ * half is safe for a validation path that must never discard a generation for
1026
+ * want of a proof.
1027
+ */
1028
+ function matchesUniversalHostInputEntries(cached, validation) {
871
1029
  const filesystem = resultFilesystem(cached.result);
872
1030
  for (const entry of validation.entries.values()) {
873
- const signature = inputMetadataSignature(entry.path, filesystem);
874
- if (signature === entry.signature)
1031
+ const evidence = inputMetadataEvidence(entry.path, filesystem);
1032
+ if (entry.signature !== undefined &&
1033
+ evidence?.signature === entry.signature)
875
1034
  continue;
876
1035
  if (entry.strict === true)
877
1036
  return false;
@@ -880,10 +1039,33 @@ function matchesUniversalHostInputs(cached, validation) {
880
1039
  if (!matchesRecordedInput(cached, entry.path)) {
881
1040
  return false;
882
1041
  }
883
- if (signature === undefined)
1042
+ if (evidence === undefined)
884
1043
  return false;
885
- entry.signature = signature;
1044
+ // Re-earn the proof under the rules the capture applies: an entry whose
1045
+ // recorded state came from reading nothing keeps its content comparison, a
1046
+ // write racing the read that just proved it records nothing, and a stamp
1047
+ // the filesystem's clock has not provably left records nothing either.
1048
+ const after = inputMetadataSignature(entry.path, filesystem);
1049
+ entry.signature =
1050
+ entry.readable && evidence.separable && after === evidence.signature
1051
+ ? evidence.signature
1052
+ : undefined;
886
1053
  }
1054
+ return true;
1055
+ }
1056
+ /**
1057
+ * Prove the universal inputs that were absent are still absent, through one
1058
+ * exact listing of the nearest directory that can settle it.
1059
+ *
1060
+ * Unlike the entries half, this one rejects on an inability to prove: a
1061
+ * directory that exists but cannot be listed certifies nothing about the
1062
+ * candidates inside it. That is the right answer for the narrow path, which has
1063
+ * no stronger proof to fall back to, but not for the whole-snapshot path, where
1064
+ * the recorded `missing` markers are re-compared directly and losing a proof
1065
+ * must not cost the cache.
1066
+ */
1067
+ function matchesUniversalHostInputProbes(cached, validation) {
1068
+ const filesystem = resultFilesystem(cached.result);
887
1069
  for (const [directory, names] of validation.missing) {
888
1070
  let entries;
889
1071
  try {
@@ -924,7 +1106,7 @@ function captureUniversalHostInputValidation(cached, currentFile) {
924
1106
  const state = envelopeDerivation(cached);
925
1107
  const validation = {
926
1108
  entries: new Map(),
927
- identities: new Set(),
1109
+ covered: new Set(),
928
1110
  missing: new Map(),
929
1111
  };
930
1112
  for (const input of selectPersistentHostInputs({
@@ -943,16 +1125,26 @@ function captureUniversalHostInputValidation(cached, currentFile) {
943
1125
  // Every persistent universal input must carry an evaluation-time
944
1126
  // fingerprint. If a plugin/native host cannot provide one, keep the fresh
945
1127
  // result but decline narrow long-lived reuse.
1128
+ let readable = false;
946
1129
  if (expected === undefined) {
947
1130
  const current = path.resolve(currentFile);
948
1131
  if (path.resolve(input) !== current)
949
1132
  return undefined;
950
1133
  // The current module may be supplied from an unsaved editor buffer. Its
951
1134
  // generation snapshot is overlaid below from `currentSource`, so a disk
952
- // fingerprint would be both unavailable and the wrong authority.
1135
+ // fingerprint would be both unavailable and the wrong authority. The
1136
+ // recorded state is the bundler's, so a signature of the disk cannot
1137
+ // stand for it however readable that disk is.
953
1138
  }
954
- else if (expected !== hostInputStateHash(input, filesystem)) {
955
- return undefined;
1139
+ else {
1140
+ const current = hostInputStateHash(input, filesystem);
1141
+ if (expected !== current) {
1142
+ return undefined;
1143
+ }
1144
+ // A path both sides agree they could not read carries no bytes for a
1145
+ // signature to stand for. It still belongs in the manifest, so the
1146
+ // content comparison keeps running for it on every delivery.
1147
+ readable = current !== null;
956
1148
  }
957
1149
  const absoluteInput = path.resolve(input);
958
1150
  if (generationRealpaths !== undefined) {
@@ -961,40 +1153,52 @@ function captureUniversalHostInputValidation(cached, currentFile) {
961
1153
  return undefined;
962
1154
  }
963
1155
  }
964
- const identity = derivationIdentity(state, input);
965
- validation.identities.add(identity);
966
- const before = inputMetadataSignature(input, filesystem);
1156
+ validation.covered.add(path.resolve(input));
1157
+ const before = inputMetadataEvidence(input, filesystem);
967
1158
  if (!matchesRecordedInput(cached, input))
968
1159
  return undefined;
969
1160
  const after = inputMetadataSignature(input, filesystem);
970
- if (before !== after)
1161
+ if (before?.signature !== after)
971
1162
  return undefined;
972
- if (after !== undefined) {
1163
+ if (before !== undefined) {
973
1164
  // Do not key this manifest by physical identity. A symlink/junction
974
1165
  // spelling and its selected target deliberately share that identity,
975
1166
  // but both lexical paths must survive so retargeting the alias is visible.
976
1167
  validation.entries.set(path.resolve(input), {
977
1168
  path: input,
1169
+ readable,
978
1170
  realpath: hostInputRealpath(input, filesystem),
979
- signature: after,
1171
+ // The signature stands in for content only when the read produced the
1172
+ // recorded bytes and the filesystem's clock has provably left the
1173
+ // stamp's tick; otherwise the content comparison keeps running until
1174
+ // the re-earn path can prove both.
1175
+ signature: readable && before.separable ? before.signature : undefined,
980
1176
  });
981
1177
  continue;
982
1178
  }
983
1179
  const probe = missingPathProbe(input, filesystem);
984
1180
  if (probe.blocker !== undefined) {
985
- const blockerIdentity = derivationIdentity(state, probe.blocker);
986
1181
  const signature = inputMetadataSignature(probe.blocker, filesystem);
987
1182
  if (signature === undefined)
988
1183
  return undefined;
989
- validation.identities.add(blockerIdentity);
1184
+ // A blocker proves a kind and an identity, not content: it is the
1185
+ // non-directory ancestor that makes everything below it unreachable, and
1186
+ // it cannot stop being that without its metadata moving. So it keeps a
1187
+ // usable signature whether or not anything read it, and exempt from the
1188
+ // clock-separability rule content signatures need — a same-tick rewrite
1189
+ // of its bytes leaves it exactly as blocking as before.
1190
+ validation.covered.add(path.resolve(probe.blocker));
990
1191
  validation.entries.set(path.resolve(probe.blocker), {
991
1192
  path: probe.blocker,
1193
+ readable: true,
992
1194
  realpath: hostInputRealpath(probe.blocker, filesystem),
993
1195
  signature,
994
1196
  strict: true,
995
1197
  });
996
1198
  continue;
997
1199
  }
1200
+ // The probe below proves this exact spelling absent, so the per-module loop
1201
+ // need not re-derive it either.
998
1202
  let names = validation.missing.get(probe.directory);
999
1203
  if (names === undefined) {
1000
1204
  names = new Set();
@@ -1002,49 +1206,181 @@ function captureUniversalHostInputValidation(cached, currentFile) {
1002
1206
  }
1003
1207
  names.add(normalizeHostInputName(probe.name, state.identityContext.caseSensitive(probe.directory)));
1004
1208
  }
1005
- state.hostInputValidation = validation;
1209
+ cached.hostInputValidation = validation;
1006
1210
  return validation;
1007
1211
  }
1212
+ /**
1213
+ * The recorded state of an input the generation read nothing from: absent, or
1214
+ * present but unreadable. It is deliberately not a hash, so no signature may
1215
+ * stand in for it: the metadata of an unreadable path holds still while the
1216
+ * bytes behind it appear.
1217
+ *
1218
+ * A directory is not this state. It records the hash of a marker instead, which
1219
+ * a signature may stand for, because the mode both halves of the signature
1220
+ * carry cannot change without the path ceasing to be that directory.
1221
+ */
1222
+ const MISSING_INPUT_STATE = "missing";
1223
+ /**
1224
+ * The highest stamp each observed filesystem clock has provably minted, keyed
1225
+ * by the operations object that observes it and, inside, by reporting device.
1226
+ *
1227
+ * A filesystem stamps a write once per clock tick, so two same-length writes
1228
+ * inside one tick are indistinguishable by metadata alone. A signature may
1229
+ * therefore stand for content only while a later write is guaranteed to move
1230
+ * it, and that guarantee needs a reference instant the observed filesystem
1231
+ * itself produced: once some stamp on the same device is strictly newer than an
1232
+ * input's modification stamp, that input's tick is provably over, so any later
1233
+ * write must mint a newer stamp and move the signature. That is git's
1234
+ * racily-clean index rule, adapted to a read-only contract: where git compares
1235
+ * entries against the index file's own timestamp, this floor accumulates every
1236
+ * stamp the cache-owned operations report, seeded per generation by
1237
+ * {@link mintFilesystemClockReference}.
1238
+ *
1239
+ * The process clock never participates: both sides of every comparison are
1240
+ * stamps the same filesystem clock minted, at the same granularity, so a
1241
+ * filesystem clock running behind (or ahead of) the host process changes
1242
+ * nothing.
1243
+ *
1244
+ * Accumulating observed stamps is deliberately weaker than git's own reference,
1245
+ * which is a single stamp git minted itself. A stamp this floor accepts may
1246
+ * instead have been _set_ rather than minted, and a set stamp is dangerous only
1247
+ * when it lands in the future: the floor is a maximum, so a restored past stamp
1248
+ * never raises it. One future-dated file — a stamp-preserving extraction or
1249
+ * copy from a machine whose clock ran ahead — pushes its device's floor past
1250
+ * the present and reopens the same-tick window for every other input on that
1251
+ * device until the clock catches up. A clock that jumps backwards strands the
1252
+ * floor above the present the same way, a different hazard from the constant
1253
+ * offset the paragraph above is about: an offset moves both operands together
1254
+ * and changes nothing, a jump moves only the present.
1255
+ *
1256
+ * The minted probe is not enough on its own to replace observed stamps: it
1257
+ * lands on the scratch volume, which is frequently not the inputs' volume (a
1258
+ * project on `D:` with `TEMP` on `C:`), and a probe-only floor would then
1259
+ * decline every _content_ signature, so every input carrying bytes would be
1260
+ * re-read on every delivery. A strict blocker keeps its signature either way,
1261
+ * because it proves a kind rather than content. Observed stamps keep the common
1262
+ * case working; the probe covers the case they cannot, a tree whose files were
1263
+ * all written inside one tick.
1264
+ */
1265
+ const FILESYSTEM_CLOCK_FLOORS = new WeakMap();
1266
+ /** Return one observed filesystem's per-device clock floor, creating it. */
1267
+ function filesystemClockFloors(filesystem) {
1268
+ let floors = FILESYSTEM_CLOCK_FLOORS.get(filesystem);
1269
+ if (floors === undefined) {
1270
+ floors = new Map();
1271
+ FILESYSTEM_CLOCK_FLOORS.set(filesystem, floors);
1272
+ }
1273
+ return floors;
1274
+ }
1275
+ /** Raise a device's clock floor with the stamps one observation reported. */
1276
+ function observeFilesystemClock(filesystem, stats) {
1277
+ const floors = filesystemClockFloors(filesystem);
1278
+ const stamp = stats.mtimeNs > stats.ctimeNs ? stats.mtimeNs : stats.ctimeNs;
1279
+ const current = floors.get(stats.dev);
1280
+ if (current === undefined || stamp > current) {
1281
+ floors.set(stats.dev, stamp);
1282
+ }
1283
+ }
1284
+ /**
1285
+ * Report whether a later write to the observed path is guaranteed to move its
1286
+ * modification stamp: the device's clock floor holds a stamp strictly newer, so
1287
+ * the tick that minted the stamp is provably over. The floor was observed
1288
+ * before the caller's content read began, which is the ordering the guarantee
1289
+ * needs — a stamp minted before the read proves every post-read write lands in
1290
+ * a newer tick.
1291
+ */
1292
+ function stampSeparable(filesystem, stats) {
1293
+ const floor = filesystemClockFloors(filesystem).get(stats.dev);
1294
+ return floor !== undefined && stats.mtimeNs < floor;
1295
+ }
1296
+ /**
1297
+ * Mint a reference instant for this generation and feed it into the observed
1298
+ * filesystem's clock floor.
1299
+ *
1300
+ * The scratch directory is a write the adapter already owns, deliberately
1301
+ * outside the project root, so stamping a probe file there produces a
1302
+ * freshly-minted "now" without touching the user's project — the analogue of
1303
+ * git writing its index. The probe is observed through the cache-owned
1304
+ * operations and keyed by the device those operations report, so it only ever
1305
+ * separates stamps on the filesystem that actually minted it; when the scratch
1306
+ * volume differs from the inputs' volume, or the observed filesystem cannot see
1307
+ * the probe at all, nothing is proven and signature recording simply stays
1308
+ * declined until passively observed stamps separate an input on their own.
1309
+ *
1310
+ * Relocating the scratch directory onto the inputs' volume would make the probe
1311
+ * universal, but it would also move every compiler and plugin temporary write
1312
+ * into the project's parent (frequently a monorepo root or a home directory)
1313
+ * for those layouts. That is a product decision about where ttsc writes, not a
1314
+ * property of this rule, so the cross-volume case degrades to more reads here
1315
+ * rather than being bought with it.
1316
+ */
1317
+ function mintFilesystemClockReference(scratchDirectory, filesystem) {
1318
+ try {
1319
+ const probe = path.join(scratchDirectory, "clock-reference");
1320
+ fs.writeFileSync(probe, "");
1321
+ observeFilesystemClock(filesystem, filesystem.lstat(probe));
1322
+ }
1323
+ catch {
1324
+ // The absence of a reference declines signature recording; it never
1325
+ // invalidates a generation.
1326
+ }
1327
+ }
1008
1328
  /** Metadata identity whose stability lets a generation reuse a content hash. */
1009
1329
  function inputMetadataSignature(file, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1330
+ return inputMetadataEvidence(file, filesystem)?.signature;
1331
+ }
1332
+ /** Observe one input's metadata signature and its clock separability. */
1333
+ function inputMetadataEvidence(file, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1010
1334
  try {
1011
1335
  const link = filesystem.lstat(file);
1336
+ observeFilesystemClock(filesystem, link);
1012
1337
  let target = link;
1013
1338
  if (link.isSymbolicLink()) {
1014
1339
  try {
1015
1340
  target = filesystem.statBigInt(file);
1341
+ observeFilesystemClock(filesystem, target);
1016
1342
  }
1017
1343
  catch {
1018
1344
  // Keep a broken link in the existing-input manifest. Its own metadata
1019
1345
  // stays stable while the target is missing, and the first successful
1020
1346
  // stat after the target appears changes this signature. Treating it as
1021
1347
  // a plain missing path would watch/list only the link's parent, which
1022
- // cannot observe a target created in another directory.
1023
- return [
1024
- link.dev,
1025
- link.ino,
1026
- link.mode,
1027
- link.size,
1028
- link.mtimeNs,
1029
- link.ctimeNs,
1030
- "missing-target",
1031
- ].join(":");
1348
+ // cannot observe a target created in another directory. It carries no
1349
+ // readable bytes, so it never needs to be separable.
1350
+ return {
1351
+ signature: [
1352
+ link.dev,
1353
+ link.ino,
1354
+ link.mode,
1355
+ link.size,
1356
+ link.mtimeNs,
1357
+ link.ctimeNs,
1358
+ "missing-target",
1359
+ ].join(":"),
1360
+ separable: false,
1361
+ };
1032
1362
  }
1033
1363
  }
1034
- return [
1035
- link.dev,
1036
- link.ino,
1037
- link.mode,
1038
- link.size,
1039
- link.mtimeNs,
1040
- link.ctimeNs,
1041
- target.dev,
1042
- target.ino,
1043
- target.mode,
1044
- target.size,
1045
- target.mtimeNs,
1046
- target.ctimeNs,
1047
- ].join(":");
1364
+ return {
1365
+ signature: [
1366
+ link.dev,
1367
+ link.ino,
1368
+ link.mode,
1369
+ link.size,
1370
+ link.mtimeNs,
1371
+ link.ctimeNs,
1372
+ target.dev,
1373
+ target.ino,
1374
+ target.mode,
1375
+ target.size,
1376
+ target.mtimeNs,
1377
+ target.ctimeNs,
1378
+ ].join(":"),
1379
+ // Both halves must be separable: a write remints the target's stamp, a
1380
+ // link retarget the link's own, and either one hiding inside its recorded
1381
+ // tick would evade the skipped content and realpath comparisons.
1382
+ separable: stampSeparable(filesystem, link) && stampSeparable(filesystem, target),
1383
+ };
1048
1384
  }
1049
1385
  catch {
1050
1386
  return undefined;
@@ -1138,17 +1474,46 @@ function missingPathProbe(file, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1138
1474
  child = directory;
1139
1475
  }
1140
1476
  }
1141
- /** Fall back to the historical whole-envelope validation without a graph. */
1477
+ /**
1478
+ * Prove one generation from its own recorded snapshot, with no help from live
1479
+ * notifications.
1480
+ *
1481
+ * This is the fallback for a graph-free envelope and for a generation whose
1482
+ * watchers could not be opened or have since failed: losing the notification
1483
+ * proof must cost the narrow path, not the cache. The walk re-proves membership
1484
+ * directly — the recorded directory signatures plus the recorded file-key
1485
+ * universe — so a created, deleted, or renamed input still invalidates without
1486
+ * any watcher.
1487
+ */
1142
1488
  function matchesCompleteInputSnapshot(cached, currentKey, source) {
1143
- if (cached.projectSnapshotComplete !== true) {
1489
+ if (cached.projectSnapshotComplete !== true ||
1490
+ cached.projectDirectories === undefined) {
1491
+ return false;
1492
+ }
1493
+ // Universal descriptor/config inputs carry a physical-identity proof that no
1494
+ // content comparison can replace: retargeting a symlinked input to a
1495
+ // byte-identical file selects a different file, and its own transitive
1496
+ // requires with it. Only the graph half of the out-of-walk snapshot records
1497
+ // realpaths, so without this the fallback would quietly hold a lower standard
1498
+ // than the narrow path it stands in for.
1499
+ const state = envelopeDerivation(cached);
1500
+ const hostValidation = cached.hostInputValidation;
1501
+ if (hostValidation === undefined ||
1502
+ !matchesUniversalHostInputEntries(cached, hostValidation)) {
1144
1503
  return false;
1145
1504
  }
1146
- const current = collectProjectInputSnapshot(cached.projectRoot, envelopeDerivation(cached).identityContext, resultFilesystem(cached.result));
1147
- if (!current.complete) {
1505
+ const declaredInputs = declaredProjectInputKeys(state, cached);
1506
+ const current = collectProjectInputSnapshot(cached.projectRoot, state.identityContext, resultFilesystem(cached.result), cached.inputSignatures === undefined
1507
+ ? undefined
1508
+ : { hashes: cached.inputHashes, signatures: cached.inputSignatures });
1509
+ if (!walkSnapshotComplete(current, declaredInputs)) {
1510
+ return false;
1511
+ }
1512
+ if (!sameProjectDirectories(cached.projectDirectories, current.projectDirectories)) {
1148
1513
  return false;
1149
1514
  }
1150
1515
  current.hashes[currentKey] = hashText(source);
1151
- if (!sameHashes(cached.inputHashes, current.hashes)) {
1516
+ if (!sameHashes(cached.inputHashes, current.hashes, declaredInputs)) {
1152
1517
  return false;
1153
1518
  }
1154
1519
  // Re-hash the out-of-walk inputs the compiler reported for this generation
@@ -1159,9 +1524,42 @@ function matchesCompleteInputSnapshot(cached, currentKey, source) {
1159
1524
  // edge requires editing an in-walk source, and a new global or config file
1160
1525
  // requires a tsconfig or package manifest change, both of which the project
1161
1526
  // walk above already detects.
1162
- const externalHashes = cached.externalInputHashes ?? {};
1163
- return (sameHashes(externalHashes, collectCachedExternalInputHashes(cached)) &&
1164
- matchesExternalInputRealpaths(cached));
1527
+ const externalCurrent = matchesCachedExternalInputs(cached);
1528
+ if (!externalCurrent.matches || !matchesExternalInputRealpaths(cached)) {
1529
+ return false;
1530
+ }
1531
+ adoptProvenSignatures(cached, {
1532
+ currentKey,
1533
+ external: externalCurrent.signatures,
1534
+ project: current.provenSignatures,
1535
+ });
1536
+ return true;
1537
+ }
1538
+ /**
1539
+ * Adopt the signatures captured while this walk proved every recorded input
1540
+ * still carries its recorded content.
1541
+ *
1542
+ * Without this, a metadata-only change — a touch, or a rewrite of identical
1543
+ * bytes — costs a re-read on every later delivery for the rest of the
1544
+ * generation's life, because the recorded signature can never match again. The
1545
+ * narrow path self-heals through {@link matchesProvenInput}; this is the same
1546
+ * refresh for the path that proves the whole snapshot at once.
1547
+ *
1548
+ * The delivered file is the single exclusion: its recorded hash is the source
1549
+ * the bundler supplied, so the disk bytes this walk read for it were compared
1550
+ * against nothing.
1551
+ */
1552
+ function adoptProvenSignatures(cached, proven) {
1553
+ const projectSignatures = (cached.inputSignatures ??= {});
1554
+ for (const [key, signature] of Object.entries(proven.project)) {
1555
+ if (key === proven.currentKey)
1556
+ continue;
1557
+ projectSignatures[key] = signature;
1558
+ }
1559
+ const externalSignatures = (cached.externalInputSignatures ??= {});
1560
+ for (const [spelling, signature] of Object.entries(proven.external)) {
1561
+ externalSignatures[spelling] = signature;
1562
+ }
1165
1563
  }
1166
1564
  /** Re-check graph-owned physical identities in complete-snapshot fallback. */
1167
1565
  function matchesExternalInputRealpaths(cached) {
@@ -1193,27 +1591,59 @@ function captureExternalInputSnapshot(cached, paths) {
1193
1591
  const graph = envelopeGraphIndexes(state, cached);
1194
1592
  const hashes = {};
1195
1593
  const realpaths = {};
1594
+ const signatures = {};
1196
1595
  let complete = true;
1596
+ // Sandwich every read between two metadata signatures. Only a signature that
1597
+ // survived its own read, and whose stamp's tick the filesystem's clock has
1598
+ // provably left ({@link stampSeparable}), may stand in for the content
1599
+ // comparison; a write racing the capture, or a stamp a same-tick rewrite
1600
+ // could still reproduce, leaves the input without one, so revalidation keeps
1601
+ // re-reading it.
1602
+ const record = (input, before, after) => {
1603
+ if (after !== undefined && before?.signature === after && before.separable)
1604
+ signatures[path.resolve(input)] = after;
1605
+ };
1197
1606
  for (const input of paths) {
1198
1607
  const identity = derivationIdentity(state, input);
1199
- if (graph.members.has(identity)) {
1608
+ // A member the envelope reported only as a resolution candidate falls
1609
+ // through to the recorded-state branch below, the same evidence a
1610
+ // plugin-declared dependency path carries. Its absence still invalidates
1611
+ // the generation when it appears, because `missing` is recorded state.
1612
+ const speculativeOnly = graph.speculative.has(identity) &&
1613
+ !graph.inputProofs.has(identity) &&
1614
+ !graph.inputProofConflicts.has(identity);
1615
+ if (graph.members.has(identity) && !speculativeOnly) {
1200
1616
  const proof = graph.inputProofs.get(identity);
1201
1617
  if (proof === undefined || graph.inputProofConflicts.has(identity)) {
1202
1618
  complete = false;
1203
1619
  continue;
1204
1620
  }
1621
+ const before = inputMetadataEvidence(input, filesystem);
1205
1622
  const currentHash = graphInputStateHash(input, filesystem);
1623
+ const after = inputMetadataSignature(input, filesystem);
1206
1624
  if (currentHash !== proof.hash ||
1207
1625
  !sameHostInputRealpath(proof.realpath, hostInputRealpath(input, filesystem), state.identityContext)) {
1208
1626
  complete = false;
1209
1627
  }
1210
- hashes[identity] = proof.hash ?? "missing";
1628
+ else if (currentHash !== null) {
1629
+ // The recorded hash is the compiler's own proof, so a signature may
1630
+ // only stand for it once the current bytes were shown to match it.
1631
+ // A path with no readable content has no bytes to stand for: it can
1632
+ // hold stable metadata while becoming readable, so it keeps the read.
1633
+ record(input, before, after);
1634
+ }
1635
+ hashes[identity] = proof.hash ?? MISSING_INPUT_STATE;
1211
1636
  realpaths[identity] = proof.realpath;
1212
1637
  continue;
1213
1638
  }
1214
- hashes[identity] = hostInputStateHash(input, filesystem) ?? "missing";
1639
+ const before = inputMetadataEvidence(input, filesystem);
1640
+ const hash = hostInputStateHash(input, filesystem);
1641
+ const after = inputMetadataSignature(input, filesystem);
1642
+ hashes[identity] = hash ?? MISSING_INPUT_STATE;
1643
+ if (hash !== null)
1644
+ record(input, before, after);
1215
1645
  }
1216
- return { complete, hashes, realpaths };
1646
+ return { complete, hashes, realpaths, signatures };
1217
1647
  }
1218
1648
  /** Verify every graph member still has the state read by the compiler. */
1219
1649
  function matchesCompilerGraphInputProofs(cached) {
@@ -1229,12 +1659,19 @@ function matchesCompilerGraphInputProofs(cached) {
1229
1659
  const state = envelopeDerivation(cached);
1230
1660
  const filesystem = resultFilesystem(cached.result);
1231
1661
  const graph = envelopeGraphIndexes(state, cached);
1232
- if (graph.inputProofConflicts.size !== 0 ||
1233
- graph.inputProofs.size !== graph.members.size) {
1662
+ if (graph.inputProofConflicts.size !== 0) {
1234
1663
  return false;
1235
1664
  }
1236
1665
  for (const identity of graph.members) {
1237
1666
  const proof = graph.inputProofs.get(identity);
1667
+ // A speculative candidate has no compile-time read to prove. Requiring one
1668
+ // would void every generation of every project whose resolution passes over
1669
+ // a higher-priority spelling, which is every project with a dependency
1670
+ // typed by a declaration file (samchon/ttsc#1245). It is validated instead
1671
+ // against the state {@link captureExternalInputSnapshot} recorded for it.
1672
+ if (proof === undefined && graph.speculative.has(identity)) {
1673
+ continue;
1674
+ }
1238
1675
  if (proof === undefined ||
1239
1676
  graphInputStateHash(proof.path, filesystem) !== proof.hash ||
1240
1677
  !sameHostInputRealpath(proof.realpath, hostInputRealpath(proof.path, filesystem), state.identityContext)) {
@@ -1272,10 +1709,10 @@ function matchesRecordedInput(cached, input) {
1272
1709
  const current = graphInput
1273
1710
  ? graphInputStateHash(input, filesystem)
1274
1711
  : hostInputStateHash(input, filesystem);
1275
- return recorded === (current ?? "missing");
1712
+ return recorded === (current ?? MISSING_INPUT_STATE);
1276
1713
  }
1277
1714
  catch {
1278
- return recorded === "missing";
1715
+ return recorded === MISSING_INPUT_STATE;
1279
1716
  }
1280
1717
  }
1281
1718
  /** Record a successfully selected module as delivered by this generation. */
@@ -1293,36 +1730,74 @@ function collectProjectInputHashes(projectRoot, identities = createHostPathIdent
1293
1730
  .hashes;
1294
1731
  }
1295
1732
  /** Hash project files and snapshot the directory topology in one walk. */
1296
- function collectProjectInputSnapshot(projectRoot, identities, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1733
+ function collectProjectInputSnapshot(projectRoot, identities, filesystem = DEFAULT_FILESYSTEM_OPERATIONS, proven) {
1297
1734
  const hashes = {};
1298
1735
  const fileSignatures = {};
1736
+ const provenSignatures = {};
1737
+ const unstableFiles = new Set();
1738
+ let attributed = true;
1299
1739
  const walked = walkProjectInputs(projectRoot, filesystem);
1300
1740
  let complete = walked.complete;
1301
1741
  for (const file of walked.files) {
1302
1742
  try {
1303
- const before = inputMetadataSignature(file, filesystem);
1743
+ const before = inputMetadataEvidence(file, filesystem);
1744
+ const key = toProjectKey(projectRoot, file, identities);
1745
+ // A file whose signature still equals the one captured around the read
1746
+ // that produced the recorded hash carries that content, so the whole
1747
+ // project does not have to be re-read to prove one delivery. A signature
1748
+ // that was already proven stays proven: its stamp has not moved since the
1749
+ // clock provably left its tick.
1750
+ if (before !== undefined &&
1751
+ proven !== undefined &&
1752
+ proven.signatures[key] === before.signature &&
1753
+ Object.prototype.hasOwnProperty.call(proven.hashes, key)) {
1754
+ hashes[key] = proven.hashes[key];
1755
+ fileSignatures[key] = before.signature;
1756
+ provenSignatures[key] = before.signature;
1757
+ continue;
1758
+ }
1304
1759
  const contents = filesystem.readFile(file);
1305
1760
  const after = inputMetadataSignature(file, filesystem);
1306
- const key = toProjectKey(projectRoot, file, identities);
1307
1761
  hashes[key] = hashText(contents);
1308
- if (before === undefined || after === undefined || before !== after) {
1762
+ if (before === undefined ||
1763
+ after === undefined ||
1764
+ before.signature !== after) {
1309
1765
  complete = false;
1766
+ unstableFiles.add(key);
1310
1767
  }
1311
1768
  else {
1312
1769
  fileSignatures[key] = after;
1770
+ // Only a signature whose stamp's tick the filesystem's clock provably
1771
+ // left before this read may later stand in for the content comparison
1772
+ // ({@link stampSeparable}); the raw signature above still participates
1773
+ // in the generation-time stability comparison.
1774
+ if (before.separable) {
1775
+ provenSignatures[key] = after;
1776
+ }
1313
1777
  }
1314
1778
  }
1315
1779
  catch {
1316
1780
  // File watchers may observe a transform while another process is moving
1317
1781
  // or deleting files. The missing key invalidates older cache entries.
1318
1782
  complete = false;
1783
+ try {
1784
+ unstableFiles.add(toProjectKey(projectRoot, file, identities));
1785
+ }
1786
+ catch {
1787
+ // Without a key the failure cannot be attributed, so it keeps the
1788
+ // whole snapshot incomplete rather than being scoped away.
1789
+ attributed = false;
1790
+ }
1319
1791
  }
1320
1792
  }
1321
1793
  return {
1322
1794
  complete,
1795
+ directoryComplete: walked.complete && attributed,
1323
1796
  fileSignatures,
1324
1797
  hashes,
1325
1798
  projectDirectories: walked.directories,
1799
+ provenSignatures,
1800
+ unstableFiles,
1326
1801
  };
1327
1802
  }
1328
1803
  /**
@@ -1387,6 +1862,9 @@ function walkProjectInputs(root, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1387
1862
  function projectDirectorySignature(directory, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1388
1863
  try {
1389
1864
  const stats = filesystem.statBigInt(directory);
1865
+ // Directory stamps are minted by the same clock as file stamps, so every
1866
+ // walk observation also raises the clock floor that separates them.
1867
+ observeFilesystemClock(filesystem, stats);
1390
1868
  if (!stats.isDirectory()) {
1391
1869
  return undefined;
1392
1870
  }
@@ -1409,6 +1887,20 @@ function sameProjectDirectories(left, right) {
1409
1887
  left.every((directory, index) => directory.path === right[index]?.path &&
1410
1888
  directory.signature === right[index]?.signature));
1411
1889
  }
1890
+ /**
1891
+ * Open one directory's change notification through the cache-owned watch seam,
1892
+ * falling back to the host's own `fs.watch`. Throws exactly where the
1893
+ * underlying watch does, so callers classify a registration failure
1894
+ * themselves.
1895
+ */
1896
+ function openDirectoryWatch(filesystem, directory, listener, onError) {
1897
+ if (filesystem.watch !== undefined) {
1898
+ return filesystem.watch(directory, listener, onError);
1899
+ }
1900
+ const watcher = fs.watch(directory, { persistent: false }, (eventType, filename) => listener(eventType, filename === null ? null : String(filename)));
1901
+ watcher.on("error", onError);
1902
+ return { close: () => watcher.close() };
1903
+ }
1412
1904
  /** Watch every walked directory for membership changes after generation. */
1413
1905
  async function createProjectMutationTracker(directories, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1414
1906
  const tracker = {
@@ -1416,7 +1908,7 @@ async function createProjectMutationTracker(directories, filesystem = DEFAULT_FI
1416
1908
  failed: false,
1417
1909
  membershipChanged: false,
1418
1910
  };
1419
- if (process.platform === "win32") {
1911
+ if (process.platform === "win32" && filesystem.watch === undefined) {
1420
1912
  await registerWindowsProjectMutationTracker(tracker, directories.map((directory) => ({ directory: directory.path })), false, filesystem);
1421
1913
  return tracker;
1422
1914
  }
@@ -1428,14 +1920,12 @@ async function createProjectMutationTracker(directories, filesystem = DEFAULT_FI
1428
1920
  };
1429
1921
  for (const directory of directories) {
1430
1922
  try {
1431
- const watcher = fs.watch(directory.path, { persistent: false }, (eventType) => {
1923
+ watchers.push(openDirectoryWatch(filesystem, directory.path, (eventType) => {
1432
1924
  if (eventType === "rename")
1433
1925
  tracker.membershipChanged = true;
1434
- });
1435
- watcher.on("error", () => {
1926
+ }, () => {
1436
1927
  tracker.failed = true;
1437
- });
1438
- watchers.push(watcher);
1928
+ }));
1439
1929
  }
1440
1930
  catch {
1441
1931
  tracker.failed = true;
@@ -1444,7 +1934,7 @@ async function createProjectMutationTracker(directories, filesystem = DEFAULT_FI
1444
1934
  return tracker;
1445
1935
  }
1446
1936
  /** Watch exact universal inputs, or their nearest existing parent if missing. */
1447
- async function createHostInputMutationTracker(inputs, filesystem = DEFAULT_FILESYSTEM_OPERATIONS) {
1937
+ async function createHostInputMutationTracker(inputs, filesystem, covered, events = "all") {
1448
1938
  const identities = createHostPathIdentityContext(filesystem);
1449
1939
  const namesByDirectory = new Map();
1450
1940
  for (const input of inputs) {
@@ -1469,11 +1959,18 @@ async function createHostInputMutationTracker(inputs, filesystem = DEFAULT_FILES
1469
1959
  }));
1470
1960
  const tracker = {
1471
1961
  close: () => undefined,
1962
+ // Coverage is the caller's claim, and it is required rather than derived
1963
+ // from the input list: an input is watched by its exact name here, but only
1964
+ // the caller knows whether the path leading to it is watched as well, which
1965
+ // is what a later validation needs before it trusts the watcher instead of
1966
+ // probing the path again. Deriving it here would hand that claim to every
1967
+ // future caller by default (samchon/ttsc#1261).
1968
+ covered,
1472
1969
  failed: false,
1473
1970
  membershipChanged: false,
1474
1971
  };
1475
- if (process.platform === "win32") {
1476
- await registerWindowsProjectMutationTracker(tracker, locations, true, filesystem);
1972
+ if (process.platform === "win32" && filesystem.watch === undefined) {
1973
+ await registerWindowsProjectMutationTracker(tracker, locations, events === "all", filesystem);
1477
1974
  return tracker;
1478
1975
  }
1479
1976
  const watchers = [];
@@ -1486,18 +1983,19 @@ async function createHostInputMutationTracker(inputs, filesystem = DEFAULT_FILES
1486
1983
  try {
1487
1984
  const names = new Set(location.names);
1488
1985
  const caseSensitive = identities.caseSensitive(location.directory);
1489
- const watcher = fs.watch(location.directory, { persistent: false }, (_eventType, filename) => {
1986
+ watchers.push(openDirectoryWatch(filesystem, location.directory, (eventType, filename) => {
1987
+ if (events === "rename" && eventType !== "rename") {
1988
+ return;
1989
+ }
1490
1990
  const reported = filename === null
1491
1991
  ? null
1492
- : normalizeHostInputName(String(filename), caseSensitive);
1992
+ : normalizeHostInputName(filename, caseSensitive);
1493
1993
  if (reported === null || names.has(reported)) {
1494
1994
  tracker.membershipChanged = true;
1495
1995
  }
1496
- });
1497
- watcher.on("error", () => {
1996
+ }, () => {
1498
1997
  tracker.failed = true;
1499
- });
1500
- watchers.push(watcher);
1998
+ }));
1501
1999
  }
1502
2000
  catch {
1503
2001
  tracker.failed = true;
@@ -1537,6 +2035,7 @@ async function registerWindowsProjectMutationTracker(tracker, locations, allEven
1537
2035
  resolveReady = resolve;
1538
2036
  });
1539
2037
  broker.trackers.set(id, { ready: resolveReady, tracker });
2038
+ tracker.drain = () => drainWindowsProjectMutationBroker(broker);
1540
2039
  tracker.close = () => {
1541
2040
  const active = broker.trackers.get(id);
1542
2041
  if (active === undefined)
@@ -1563,7 +2062,11 @@ async function registerWindowsProjectMutationTracker(tracker, locations, allEven
1563
2062
  }
1564
2063
  finally {
1565
2064
  broker.pendingRegistrations -= 1;
1566
- if (broker.pendingRegistrations === 0) {
2065
+ // `ref`/`unref` is a flag rather than a counter, so this must not clear a
2066
+ // reference an in-flight acknowledgement is holding: a delivery waiting on
2067
+ // a reply over an unreferenced channel lets the loop empty and the process
2068
+ // exit mid-build.
2069
+ if (broker.pendingRegistrations === 0 && broker.pendingDrains === 0) {
1567
2070
  broker.child.unref();
1568
2071
  broker.child.channel?.unref();
1569
2072
  }
@@ -1579,7 +2082,9 @@ function getWindowsProjectMutationBroker() {
1579
2082
  });
1580
2083
  const broker = {
1581
2084
  child,
2085
+ drains: new Map(),
1582
2086
  nextId: 1,
2087
+ pendingDrains: 0,
1583
2088
  pendingRegistrations: 0,
1584
2089
  trackers: new Map(),
1585
2090
  };
@@ -1589,6 +2094,12 @@ function getWindowsProjectMutationBroker() {
1589
2094
  registration.ready();
1590
2095
  }
1591
2096
  broker.trackers.clear();
2097
+ // A broker that died answers no round-trip. Release every waiter instead of
2098
+ // stalling the deliveries behind them; their trackers are failed now, so
2099
+ // validation falls back to proving the generation from its own state.
2100
+ for (const release of broker.drains.values())
2101
+ release();
2102
+ broker.drains.clear();
1592
2103
  if (windowsProjectMutationBroker === broker) {
1593
2104
  windowsProjectMutationBroker = undefined;
1594
2105
  }
@@ -1601,6 +2112,14 @@ function getWindowsProjectMutationBroker() {
1601
2112
  const record = message;
1602
2113
  if (typeof record.id !== "number")
1603
2114
  return;
2115
+ if (record.drained === true) {
2116
+ // Every event the child had already sent arrived before this reply, since
2117
+ // one IPC channel delivers in order.
2118
+ const release = broker.drains.get(record.id);
2119
+ broker.drains.delete(record.id);
2120
+ release?.();
2121
+ return;
2122
+ }
1604
2123
  const registration = broker.trackers.get(record.id);
1605
2124
  if (registration === undefined)
1606
2125
  return;
@@ -1615,10 +2134,72 @@ function getWindowsProjectMutationBroker() {
1615
2134
  windowsProjectMutationBroker = broker;
1616
2135
  return broker;
1617
2136
  }
2137
+ /**
2138
+ * Ask the Windows broker to acknowledge, and resolve when it does.
2139
+ *
2140
+ * The child answers after a turn of its own loop, so a watch callback it had
2141
+ * already queued has run, and the ordered IPC channel puts every message it
2142
+ * sent before the reply ahead of the reply. That is the same proof an
2143
+ * in-process watcher gets from a macrotask turn, rather than the fixed wait
2144
+ * this replaces, which guessed at the crossing (samchon/ttsc#1272).
2145
+ *
2146
+ * A broker that never answers must not hold a delivery: the wait falls back to
2147
+ * the previous fixed grace, after which validation proceeds against whatever
2148
+ * the tracker knows, exactly as it did before.
2149
+ */
2150
+ function drainWindowsProjectMutationBroker(broker) {
2151
+ // Every tracker of a generation lives in one broker, so one acknowledgement
2152
+ // answers for all of them. Sharing the in-flight round-trip keeps a settle to
2153
+ // a single crossing.
2154
+ broker.draining ??= startWindowsProjectMutationDrain(broker).finally(() => {
2155
+ broker.draining = undefined;
2156
+ });
2157
+ return broker.draining;
2158
+ }
2159
+ function startWindowsProjectMutationDrain(broker) {
2160
+ return new Promise((resolve) => {
2161
+ const id = broker.nextId++;
2162
+ let settled = false;
2163
+ const release = () => {
2164
+ if (settled)
2165
+ return;
2166
+ settled = true;
2167
+ clearTimeout(timer);
2168
+ broker.drains.delete(id);
2169
+ broker.pendingDrains -= 1;
2170
+ if (broker.pendingDrains === 0 && broker.pendingRegistrations === 0) {
2171
+ broker.child.unref();
2172
+ broker.child.channel?.unref();
2173
+ }
2174
+ resolve();
2175
+ };
2176
+ // Hold the channel open while the acknowledgement is outstanding. The
2177
+ // broker is unreferenced between requests so it never keeps a host alive,
2178
+ // and a reply is the only thing this promise can be resolved by: without
2179
+ // the reference the loop can empty while a delivery waits here, and the
2180
+ // process exits mid-build with nothing to report.
2181
+ broker.pendingDrains += 1;
2182
+ broker.child.ref();
2183
+ broker.child.channel?.ref();
2184
+ const timer = setTimeout(release, WINDOWS_MUTATION_DRAIN_FALLBACK_MS);
2185
+ broker.drains.set(id, release);
2186
+ if (broker.child.send?.({ id, op: "drain" }) !== true) {
2187
+ release();
2188
+ }
2189
+ });
2190
+ }
2191
+ /** The wait a broker that stopped answering degrades to. */
2192
+ const WINDOWS_MUTATION_DRAIN_FALLBACK_MS = 10;
1618
2193
  const WINDOWS_WATCH_BROKER_SOURCE = [
1619
2194
  'const fs = require("node:fs");',
1620
2195
  "const groups = new Map();",
1621
2196
  'process.on("message", (message) => {',
2197
+ ' if (message.op === "drain") {',
2198
+ // Two turns, not one: the first lets the loop poll for watch completions the
2199
+ // kernel had already queued, the second answers after their callbacks ran.
2200
+ " setImmediate(() => setImmediate(() => process.send?.({ drained: true, id: message.id })));",
2201
+ " return;",
2202
+ " }",
1622
2203
  ' if (message.op === "remove") {',
1623
2204
  " close(message.id);",
1624
2205
  " return;",
@@ -1651,33 +2232,64 @@ const WINDOWS_WATCH_BROKER_SOURCE = [
1651
2232
  " groups.delete(id);",
1652
2233
  "}",
1653
2234
  ].join("\n");
1654
- /** Report whether live directory notifications preserve project membership. */
1655
- function matchesProjectMembership(cached) {
1656
- const tracker = cached.projectMutationTracker;
1657
- return (tracker !== undefined &&
1658
- tracker.failed === false &&
1659
- tracker.membershipChanged === false);
2235
+ /**
2236
+ * Report whether either live notification observed a membership event. This is
2237
+ * positive evidence that the generation is stale, so it outranks the question
2238
+ * of whether the notifications still work.
2239
+ */
2240
+ function reportsMembershipChange(cached) {
2241
+ return (cached.projectMutationTracker?.membershipChanged === true ||
2242
+ cached.hostInputMutationTracker?.membershipChanged === true ||
2243
+ cached.candidateMutationTracker?.membershipChanged === true);
1660
2244
  }
1661
2245
  /**
1662
- * Yield once before persistent validation so synchronous edits can reach the
1663
- * directory watchers that guard membership. Concurrent sibling deliveries share
1664
- * the same barrier.
2246
+ * Report whether the live notifications can still prove membership. A watcher
2247
+ * that failed to register, or that errored after the generation was produced,
2248
+ * proves nothing either way — it never proves the generation stale.
2249
+ */
2250
+ function notificationsProveMembership(cached) {
2251
+ for (const tracker of [
2252
+ cached.projectMutationTracker,
2253
+ cached.hostInputMutationTracker,
2254
+ ]) {
2255
+ if (tracker === undefined || tracker.failed) {
2256
+ return false;
2257
+ }
2258
+ }
2259
+ // The candidate tracker is optional: a generation with no absent candidate
2260
+ // opens none, and one that declined to watch them left the per-delivery probe
2261
+ // in place. Only a tracker that exists and has failed withdraws the proof.
2262
+ return cached.candidateMutationTracker?.failed !== true;
2263
+ }
2264
+ /**
2265
+ * Yield to the loop the tracker's own watcher callbacks are queued on.
2266
+ *
2267
+ * Two turns for the same reason the broker takes two: the first gives the loop
2268
+ * a poll phase for completions the kernel had already queued, the second runs
2269
+ * after the callbacks they produced.
2270
+ */
2271
+ function drainOnNextTurn() {
2272
+ return new Promise((resolve) => setImmediate(() => setImmediate(resolve)));
2273
+ }
2274
+ /**
2275
+ * Settle every notification the trackers' watchers have already dispatched,
2276
+ * before persistent validation reads their verdict.
2277
+ *
2278
+ * A synchronous edit returns before its watch event is applied, so without this
2279
+ * a delivery could validate against a tracker that has not been told yet. Each
2280
+ * tracker drains through its own channel, which is a macrotask turn for a
2281
+ * watcher on this loop and an ordered round-trip for one inside the Windows
2282
+ * broker. Concurrent sibling deliveries share the barrier one of them started.
1665
2283
  */
1666
2284
  async function settleProjectMutationEvents(cached) {
1667
2285
  const trackers = [
1668
2286
  cached.projectMutationTracker,
1669
2287
  cached.hostInputMutationTracker,
2288
+ cached.candidateMutationTracker,
1670
2289
  ].filter((tracker) => tracker !== undefined);
1671
2290
  await Promise.all(trackers.map(async (tracker) => {
1672
- tracker.settle ??= new Promise((resolve) => {
1673
- const settled = () => {
1674
- tracker.settle = undefined;
1675
- resolve();
1676
- };
1677
- if (process.platform === "win32")
1678
- setTimeout(settled, 10);
1679
- else
1680
- setImmediate(settled);
2291
+ tracker.settle ??= (tracker.drain ?? drainOnNextTurn)().finally(() => {
2292
+ tracker.settle = undefined;
1681
2293
  });
1682
2294
  await tracker.settle;
1683
2295
  }));
@@ -1746,27 +2358,64 @@ function collectExternalInputHashes(paths, filesystem = DEFAULT_FILESYSTEM_OPERA
1746
2358
  if (identity in hashes) {
1747
2359
  continue;
1748
2360
  }
1749
- hashes[identity] = hostInputStateHash(file, filesystem) ?? "missing";
2361
+ hashes[identity] =
2362
+ hostInputStateHash(file, filesystem) ?? MISSING_INPUT_STATE;
1750
2363
  }
1751
2364
  return hashes;
1752
2365
  }
1753
- /** Re-hash a cached mixed graph/dependency input set with its owning codec. */
1754
- function collectCachedExternalInputHashes(cached) {
1755
- const hashes = {};
2366
+ /**
2367
+ * Re-check a cached mixed graph/dependency input set with its owning codec,
2368
+ * reusing the recorded hash of any input whose metadata signature still holds
2369
+ * and reporting the signatures this pass captured.
2370
+ *
2371
+ * The caller adopts those signatures only once every input is proven unchanged,
2372
+ * so a signature never outlives the content comparison that justified it.
2373
+ */
2374
+ function matchesCachedExternalInputs(cached) {
2375
+ const signatures = {};
2376
+ let matches = true;
1756
2377
  const state = envelopeDerivation(cached);
1757
2378
  const graphRealpaths = cached.externalInputRealpaths ?? {};
1758
2379
  const filesystem = resultFilesystem(cached.result);
2380
+ const recordedHashes = cached.externalInputHashes ?? {};
2381
+ const recordedSignatures = cached.externalInputSignatures ?? {};
2382
+ // Compare each spelling against the recorded state under its own name. Two
2383
+ // spellings share one identity exactly when they selected one physical file
2384
+ // at generation time, which is the state a retarget ends, so neither may
2385
+ // answer for the other: skipping the second would leave a retargeted alias
2386
+ // unvalidated, and comparing them only through a shared key would let
2387
+ // whichever came first decide.
1759
2388
  for (const file of cached.externalInputPaths ??
1760
2389
  Object.keys(cached.externalInputHashes ?? {})) {
1761
2390
  const identity = derivationIdentity(state, file);
1762
- if (identity in hashes)
2391
+ const spelling = path.resolve(file);
2392
+ // Reuse the recorded hash of an out-of-walk input whose signature still
2393
+ // equals the one captured around the read that proved it. The signature is
2394
+ // keyed by this exact spelling, so an alias of the same physical file
2395
+ // cannot answer for it.
2396
+ const before = inputMetadataEvidence(file, filesystem);
2397
+ if (before !== undefined &&
2398
+ Object.prototype.hasOwnProperty.call(recordedSignatures, spelling) &&
2399
+ Object.prototype.hasOwnProperty.call(recordedHashes, identity) &&
2400
+ before.signature === recordedSignatures[spelling]) {
1763
2401
  continue;
1764
- hashes[identity] =
1765
- (Object.prototype.hasOwnProperty.call(graphRealpaths, identity)
1766
- ? graphInputStateHash(file, filesystem)
1767
- : hostInputStateHash(file, filesystem)) ?? "missing";
2402
+ }
2403
+ const hash = Object.prototype.hasOwnProperty.call(graphRealpaths, identity)
2404
+ ? graphInputStateHash(file, filesystem)
2405
+ : hostInputStateHash(file, filesystem);
2406
+ const after = inputMetadataSignature(file, filesystem);
2407
+ if (!Object.prototype.hasOwnProperty.call(recordedHashes, identity) ||
2408
+ recordedHashes[identity] !== (hash ?? MISSING_INPUT_STATE)) {
2409
+ matches = false;
2410
+ }
2411
+ if (hash !== null &&
2412
+ after !== undefined &&
2413
+ before?.signature === after &&
2414
+ before.separable) {
2415
+ signatures[spelling] = after;
2416
+ }
1768
2417
  }
1769
- return hashes;
2418
+ return { matches, signatures };
1770
2419
  }
1771
2420
  /**
1772
2421
  * Derive the absolute out-of-walk input set of a whole project transform: the
@@ -1861,6 +2510,156 @@ function selectExternalInputPaths(props) {
1861
2510
  output.sort();
1862
2511
  return output;
1863
2512
  }
2513
+ /**
2514
+ * The generation's resolution candidates that do not exist, so its host-input
2515
+ * watcher can be told to announce their creation.
2516
+ *
2517
+ * A missing candidate is the one input class no proof can be memoized for: its
2518
+ * metadata cannot be read, so the signature shortcut that stands in for every
2519
+ * other input's comparison never applies, and every delivery that reaches it
2520
+ * probes the filesystem again. Watching the name instead turns that repeated
2521
+ * probe into one notification for the whole generation, using the same channel
2522
+ * and the same failure rules the universal inputs already run under
2523
+ * (samchon/ttsc#1261).
2524
+ *
2525
+ * Only absent candidates qualify. One that exists is validated by content and
2526
+ * physical identity like any other input, and adding it here would replace the
2527
+ * generation for a change that cannot affect a resolution the compiler already
2528
+ * declined to take.
2529
+ */
2530
+ function selectNotifiableAbsentInputs(props) {
2531
+ const empty = { candidates: [], watched: [] };
2532
+ if (props.result.type === "exception") {
2533
+ return empty;
2534
+ }
2535
+ const graph = props.result.graph;
2536
+ if (graph === undefined) {
2537
+ return empty;
2538
+ }
2539
+ const identities = createHostPathIdentityContext(props.filesystem);
2540
+ const excluded = props.temporaryTsconfig === undefined
2541
+ ? undefined
2542
+ : pathIdentityKey(props.temporaryTsconfig, identities);
2543
+ const resolvedProjectRoot = path.resolve(props.projectRoot);
2544
+ const output = [];
2545
+ const watched = [];
2546
+ const directories = new Set();
2547
+ // Two namespaces, deliberately not one set: candidates are the paths a
2548
+ // delivery may stop probing, while the chain holds the directories that carry
2549
+ // them. Sharing a set would let one silently answer for the other.
2550
+ const seen = new Set();
2551
+ const chain = new Set();
2552
+ for (const candidates of Object.values(graph.candidates ?? {})) {
2553
+ if (!Array.isArray(candidates)) {
2554
+ continue;
2555
+ }
2556
+ for (const candidate of candidates) {
2557
+ if (typeof candidate !== "string" || candidate.length === 0) {
2558
+ continue;
2559
+ }
2560
+ const absolute = path.resolve(props.projectRoot, candidate);
2561
+ const spelling = path.resolve(absolute);
2562
+ if (seen.has(spelling) ||
2563
+ (excluded !== undefined &&
2564
+ pathIdentityKey(absolute, identities) === excluded) ||
2565
+ props.filesystem.exists(absolute)) {
2566
+ continue;
2567
+ }
2568
+ seen.add(spelling);
2569
+ // Collect the components of the lexical path, by the name each carries in
2570
+ // its own parent. The watcher a missing path opens follows the spelling
2571
+ // to a physical directory, so retargeting a link along the way moves the
2572
+ // answer without touching what is watched: in a pnpm layout
2573
+ // `node_modules/<pkg>` is exactly such a link, and reinstalling it makes
2574
+ // a candidate appear behind a watch still looking at the old store
2575
+ // directory. Watching `<pkg>` inside `node_modules` is what reports that.
2576
+ //
2577
+ // The collection stops at the project root, and a spelling that leaves
2578
+ // the project subtree before reaching it is not claimed at all. Above
2579
+ // that line the components are the machine's own layout rather than the
2580
+ // project's, and watching those entries costs a generation whenever an
2581
+ // unrelated process touches anything inside them; a candidate whose path
2582
+ // runs outside the subtree therefore keeps the probe it always had rather
2583
+ // than a proof this cannot complete.
2584
+ const components = [];
2585
+ let reachedProject = false;
2586
+ for (let child = path.dirname(spelling), parent = path.dirname(child); parent !== child; child = parent, parent = path.dirname(child)) {
2587
+ if (insideProject(child, resolvedProjectRoot)) {
2588
+ components.push(child);
2589
+ continue;
2590
+ }
2591
+ // Compared through `path.relative` rather than by string, so a
2592
+ // spelling that differs from the root only in case still counts as
2593
+ // having arrived where the platform says it has.
2594
+ reachedProject = path.relative(child, resolvedProjectRoot).length === 0;
2595
+ break;
2596
+ }
2597
+ if (!reachedProject) {
2598
+ continue;
2599
+ }
2600
+ output.push(absolute);
2601
+ watched.push(absolute);
2602
+ for (const component of components) {
2603
+ if (chain.has(component))
2604
+ break;
2605
+ chain.add(component);
2606
+ watched.push(component);
2607
+ directories.add(path.dirname(component));
2608
+ }
2609
+ directories.add(path.dirname(spelling));
2610
+ }
2611
+ }
2612
+ if (directories.size > NOTIFIABLE_ABSENCE_DIRECTORY_LIMIT) {
2613
+ // Past this many distinct directories the watch registration is the more
2614
+ // expensive half: a host that runs out of watch descriptors fails the
2615
+ // tracker, and a failed tracker sends every delivery to complete-snapshot
2616
+ // validation, which re-hashes the whole project. Declining to watch leaves
2617
+ // the per-delivery probe in place, which is what this replaces and is far
2618
+ // cheaper than that.
2619
+ return empty;
2620
+ }
2621
+ output.sort();
2622
+ watched.sort();
2623
+ return { candidates: output, watched };
2624
+ }
2625
+ /**
2626
+ * Report whether a directory lies strictly below the project root.
2627
+ *
2628
+ * The boundary of what a generation may watch on a candidate's behalf: what the
2629
+ * project contains is its own layout, while the project root and everything
2630
+ * above it belongs to the machine, which nobody retargets and which changes for
2631
+ * reasons no generation should hear about.
2632
+ */
2633
+ function insideProject(directory, projectRoot) {
2634
+ const relative = path.relative(path.resolve(projectRoot), path.resolve(directory));
2635
+ // An empty result is the platform saying the two name the same directory,
2636
+ // which it answers for spellings that differ only in case where the path
2637
+ // module folds case. The root itself is not below itself, so the walk stops
2638
+ // there rather than one level past it.
2639
+ if (relative.length === 0) {
2640
+ return false;
2641
+ }
2642
+ // `..` alone and `../` climb out, and an absolute answer means another drive
2643
+ // or share entirely; a directory literally named `..x` does neither, which a
2644
+ // plain prefix test would misread. The project walk's own containment check
2645
+ // spells it the same way.
2646
+ return (relative !== ".." &&
2647
+ !relative.startsWith(`..${path.sep}`) &&
2648
+ !path.isAbsolute(relative));
2649
+ }
2650
+ /**
2651
+ * Distinct directories the absent-candidate watch may open before it declines.
2652
+ *
2653
+ * Sized well below the inotify per-user default so a project's own walk keeps
2654
+ * its share, and far above the distinct `node_modules` package directories a
2655
+ * real dependency graph produces.
2656
+ *
2657
+ * Counted lexically, over the parents of every watched name. A missing subtree
2658
+ * collapses onto the one watch its nearest existing ancestor carries, so the
2659
+ * count is an upper bound on the watches actually opened rather than their
2660
+ * number; the bound stays sound and is merely not tight.
2661
+ */
2662
+ const NOTIFIABLE_ABSENCE_DIRECTORY_LIMIT = 512;
1864
2663
  function isIgnoredProjectDirectory(name) {
1865
2664
  return (name === ".git" ||
1866
2665
  name === ".ttsc" ||
@@ -1878,7 +2677,29 @@ function isIgnoredProjectDirectory(name) {
1878
2677
  name === "temp" ||
1879
2678
  name === "tmp");
1880
2679
  }
1881
- function sameHashes(left, right) {
2680
+ /**
2681
+ * Compare two project-walk snapshots.
2682
+ *
2683
+ * `keys` narrows the comparison to the generation's declared inputs. The walk
2684
+ * hashes every file under the project root, but only a file the compile
2685
+ * actually consumed can change an output, and a project root is a working
2686
+ * directory: a framework's generated types, a log, a coverage report, or a test
2687
+ * artifact appears and changes there while a compile runs. Comparing those
2688
+ * would declare the generation incoherent and cost a whole-project recompile
2689
+ * for every remaining module (samchon/ttsc#1246). Files entering or leaving the
2690
+ * project remain covered by the directory-membership snapshot, which is the one
2691
+ * thing a content comparison cannot see. An envelope that declares no input set
2692
+ * (a graph-free legacy host) passes `undefined` and keeps the whole-walk
2693
+ * comparison.
2694
+ */
2695
+ function sameHashes(left, right, keys) {
2696
+ if (keys !== undefined) {
2697
+ for (const key of keys) {
2698
+ if (left[key] !== right[key])
2699
+ return false;
2700
+ }
2701
+ return true;
2702
+ }
1882
2703
  const leftKeys = Object.keys(left);
1883
2704
  const rightKeys = Object.keys(right);
1884
2705
  if (leftKeys.length !== rightKeys.length) {
@@ -1886,6 +2707,126 @@ function sameHashes(left, right) {
1886
2707
  }
1887
2708
  return leftKeys.every((key) => right[key] === left[key]);
1888
2709
  }
2710
+ /**
2711
+ * Whether a project-walk snapshot is coherent for the inputs that matter.
2712
+ *
2713
+ * The walk reads every file under the project root, so a file nothing compiled
2714
+ * (a log being appended, a coverage report being written, a generated artifact
2715
+ * being replaced) can fail its own read sandwich while every input holds still.
2716
+ * That is not evidence about the generation, and treating it as such costs a
2717
+ * whole-project recompile per delivered module. A walk that could not enumerate
2718
+ * a directory, or a file-level failure this snapshot could not attribute to a
2719
+ * key, still taints everything: neither can be shown to leave the inputs
2720
+ * alone.
2721
+ */
2722
+ function walkSnapshotComplete(snapshot, declared) {
2723
+ if (declared === undefined) {
2724
+ return snapshot.complete;
2725
+ }
2726
+ if (!snapshot.directoryComplete) {
2727
+ return false;
2728
+ }
2729
+ for (const key of snapshot.unstableFiles) {
2730
+ if (declared.has(key))
2731
+ return false;
2732
+ }
2733
+ return true;
2734
+ }
2735
+ /** {@link selectDeclaredProjectInputKeys} memoized per envelope generation. */
2736
+ function declaredProjectInputKeys(state, cached) {
2737
+ if (state.declaredInputKeysBuilt !== true) {
2738
+ state.declaredInputKeys = selectDeclaredProjectInputKeys({
2739
+ identities: state.identityContext,
2740
+ projectRoot: cached.projectRoot,
2741
+ result: cached.result,
2742
+ });
2743
+ state.declaredInputKeysBuilt = true;
2744
+ }
2745
+ return state.declaredInputKeys;
2746
+ }
2747
+ /**
2748
+ * Project-walk keys of every input the envelope declares: the reference graph's
2749
+ * edge endpoints, globals, config chain, and resolution candidates, plus the
2750
+ * universal host inputs. Returns `undefined` for an envelope with no graph,
2751
+ * which declares no input set and therefore keeps whole-walk comparison.
2752
+ */
2753
+ function selectDeclaredProjectInputKeys(props) {
2754
+ if (props.result.type === "exception" || props.result.graph === undefined) {
2755
+ return undefined;
2756
+ }
2757
+ const graph = props.result.graph;
2758
+ const keys = new Set();
2759
+ const add = (entry) => {
2760
+ if (typeof entry !== "string" || entry.length === 0)
2761
+ return;
2762
+ keys.add(toProjectKey(props.projectRoot, path.resolve(props.projectRoot, entry), props.identities));
2763
+ };
2764
+ for (const [source, targets] of Object.entries(graph.edges ?? {})) {
2765
+ add(source);
2766
+ if (Array.isArray(targets))
2767
+ for (const target of targets)
2768
+ add(target);
2769
+ }
2770
+ if (Array.isArray(graph.globals))
2771
+ for (const input of graph.globals)
2772
+ add(input);
2773
+ if (Array.isArray(graph.configs))
2774
+ for (const input of graph.configs)
2775
+ add(input);
2776
+ for (const [source, candidates] of Object.entries(graph.candidates ?? {})) {
2777
+ add(source);
2778
+ if (Array.isArray(candidates))
2779
+ for (const entry of candidates)
2780
+ add(entry);
2781
+ }
2782
+ if (Array.isArray(props.result.hostInputs))
2783
+ for (const input of props.result.hostInputs)
2784
+ add(input);
2785
+ // Plugin-reported dependencies are inputs the graph never sees: a utility
2786
+ // plugin's own config file is consulted by the plugin, not by the compiler.
2787
+ for (const reported of Object.values(props.result.dependencies ?? {})) {
2788
+ if (Array.isArray(reported))
2789
+ for (const input of reported)
2790
+ add(input);
2791
+ }
2792
+ return keys;
2793
+ }
2794
+ /**
2795
+ * Project roots already told they cannot reuse a compile, so a build reports
2796
+ * the condition once instead of once per module.
2797
+ */
2798
+ const REPORTED_UNREUSABLE_GENERATIONS = new Set();
2799
+ /**
2800
+ * Report, once per project root, that a generation cannot be reused.
2801
+ *
2802
+ * Every module of the build then recompiles the whole project, so the condition
2803
+ * is the difference between one compile and one compile per module. It stayed
2804
+ * invisible for the whole life of samchon/ttsc#970: consumers saw only a build
2805
+ * that never finished, and each investigation had to rediscover the cause from
2806
+ * outside. A named reason turns the next occurrence into a bug report instead
2807
+ * of an archaeology session.
2808
+ */
2809
+ function reportUnreusableGeneration(cached, evidence) {
2810
+ const missing = [
2811
+ ...(evidence.walkStable ? [] : ["a stable project snapshot"]),
2812
+ ...(evidence.graphProofs ? [] : ["compiler proofs for its graph inputs"]),
2813
+ ...(evidence.externalInputs
2814
+ ? []
2815
+ : ["a complete out-of-walk input snapshot"]),
2816
+ ...(evidence.universalInputs ? [] : ["a universal host-input manifest"]),
2817
+ ];
2818
+ const key = `${cached.projectRoot}\0${missing.join(",")}`;
2819
+ if (REPORTED_UNREUSABLE_GENERATIONS.has(key)) {
2820
+ return;
2821
+ }
2822
+ REPORTED_UNREUSABLE_GENERATIONS.add(key);
2823
+ process.stderr.write(`ttsc: the transform cache cannot reuse this project's compile, so every ` +
2824
+ `module recompiles the whole project.\n` +
2825
+ ` project: ${cached.projectRoot}\n` +
2826
+ ` missing: ${missing.join("; ")}\n` +
2827
+ ` Please report this at https://github.com/samchon/ttsc/issues with ` +
2828
+ `this message.\n`);
2829
+ }
1889
2830
  function hashText(input) {
1890
2831
  return crypto.createHash("sha256").update(input).digest("hex");
1891
2832
  }
@@ -1895,6 +2836,9 @@ async function transformProject(props) {
1895
2836
  let tracker;
1896
2837
  let retainTracker = false;
1897
2838
  let hostInputTracker;
2839
+ let candidateTracker;
2840
+ let retainHostInputTracker = false;
2841
+ let retainCandidateTracker = false;
1898
2842
  try {
1899
2843
  const configured = createTransformTsconfig(props, scratchDirectory);
1900
2844
  const temporaryTsconfig = configured.path === props.tsconfig ? undefined : configured.path;
@@ -1918,15 +2862,50 @@ async function transformProject(props) {
1918
2862
  env: transformScratchEnvironment(scratchDirectory),
1919
2863
  }).transform());
1920
2864
  TRANSFORM_RESULT_FILESYSTEM.set(result, props.filesystem);
2865
+ // Mint the generation's clock reference after the compile and before any
2866
+ // signature-recording read below, so every input written before the
2867
+ // compile sits in a provably finished tick when its signature is captured.
2868
+ mintFilesystemClockReference(scratchDirectory, props.filesystem);
1921
2869
  const persistentHostInputs = selectPersistentHostInputs({
1922
2870
  filesystem: props.filesystem,
1923
2871
  projectRoot,
1924
2872
  result,
1925
2873
  temporaryTsconfig,
1926
2874
  });
2875
+ // The generation's absent resolution candidates, which get a watcher of
2876
+ // their own below; watching one is what lets a delivery stop probing it
2877
+ // (samchon/ttsc#1261). The validation manifest stays built from the
2878
+ // universal inputs alone, so nothing else about a candidate changes.
2879
+ //
2880
+ // Derived only where a tracker could carry it: a build-scoped adapter opens
2881
+ // no watcher, so probing every candidate's existence here would be work
2882
+ // whose answer nothing can read.
2883
+ const notifiableAbsence = props.trackProjectMembership
2884
+ ? selectNotifiableAbsentInputs({
2885
+ filesystem: props.filesystem,
2886
+ projectRoot,
2887
+ result,
2888
+ temporaryTsconfig,
2889
+ })
2890
+ : { candidates: [], watched: [] };
1927
2891
  hostInputTracker = props.trackProjectMembership
1928
- ? await createHostInputMutationTracker(persistentHostInputs, props.filesystem)
2892
+ ? await createHostInputMutationTracker(persistentHostInputs, props.filesystem,
2893
+ // A universal input never reaches the per-input loop that consults a
2894
+ // coverage claim: an absent one is proven by its directory listing
2895
+ // instead, which re-resolves the spelling every delivery.
2896
+ new Set())
1929
2897
  : undefined;
2898
+ // The candidates and the directories carrying them get their own tracker,
2899
+ // listening for renames alone. Every event that can make one of these
2900
+ // paths appear is a rename — the file itself, or a component of the path
2901
+ // being created, replaced, or retargeted — so nothing is given up, while a
2902
+ // backend that reports a write below a directory as a change to that
2903
+ // directory's entry (Windows does) would otherwise replace the generation
2904
+ // every time a bundler wrote inside `node_modules`.
2905
+ candidateTracker =
2906
+ notifiableAbsence.watched.length !== 0
2907
+ ? await createHostInputMutationTracker(notifiableAbsence.watched, props.filesystem, new Set(notifiableAbsence.candidates), "rename")
2908
+ : undefined;
1930
2909
  const externalInputPaths = selectExternalInputPaths({
1931
2910
  filesystem: props.filesystem,
1932
2911
  projectRoot,
@@ -1934,18 +2913,34 @@ async function transformProject(props) {
1934
2913
  temporaryTsconfig,
1935
2914
  });
1936
2915
  const inputSnapshot = collectProjectInputSnapshot(projectRoot, identities, props.filesystem);
1937
- let stableProjectSnapshot = before.complete &&
1938
- inputSnapshot.complete &&
1939
- sameHashes(before.hashes, inputSnapshot.hashes) &&
1940
- sameHashes(before.fileSignatures, inputSnapshot.fileSignatures) &&
2916
+ // Whether the recorded snapshot describes one coherent state of the
2917
+ // project. A membership event during the compile taints it exactly like an
2918
+ // unstable walk pair; whether notifications can be *opened* is a separate
2919
+ // fact, tracked below, because a generation with no watcher is still
2920
+ // provable from its own recorded state.
2921
+ const declaredInputs = selectDeclaredProjectInputKeys({
2922
+ identities,
2923
+ projectRoot,
2924
+ result,
2925
+ });
2926
+ const walkStable = walkSnapshotComplete(before, declaredInputs) &&
2927
+ walkSnapshotComplete(inputSnapshot, declaredInputs) &&
2928
+ sameHashes(before.hashes, inputSnapshot.hashes, declaredInputs) &&
2929
+ sameHashes(before.fileSignatures, inputSnapshot.fileSignatures, declaredInputs) &&
1941
2930
  sameProjectDirectories(before.projectDirectories, inputSnapshot.projectDirectories) &&
1942
- tracker?.failed !== true &&
1943
2931
  tracker?.membershipChanged !== true &&
2932
+ hostInputTracker?.membershipChanged !== true &&
2933
+ candidateTracker?.membershipChanged !== true;
2934
+ const notificationsAvailable = tracker?.failed !== true &&
1944
2935
  hostInputTracker?.failed !== true &&
1945
- hostInputTracker?.membershipChanged !== true;
2936
+ candidateTracker?.failed !== true;
1946
2937
  // Overlay the in-memory source only after proving the two on-disk snapshots
1947
2938
  // stable; an unsaved editor buffer must not look like a compile-time race.
1948
- inputSnapshot.hashes[toProjectKey(projectRoot, props.currentFile, identities)] = hashText(props.currentSource);
2939
+ const currentFileKey = toProjectKey(projectRoot, props.currentFile, identities);
2940
+ inputSnapshot.hashes[currentFileKey] = hashText(props.currentSource);
2941
+ // That overlay makes this one key the only recorded hash a disk signature
2942
+ // cannot stand for: the bytes it names came from the bundler, not the file.
2943
+ delete inputSnapshot.provenSignatures[currentFileKey];
1949
2944
  const cached = {
1950
2945
  // Capture the out-of-walk input hashes while the generation is fresh so
1951
2946
  // cache validation can re-check them; computed before dispose so the
@@ -1954,6 +2949,7 @@ async function transformProject(props) {
1954
2949
  externalInputRealpaths: {},
1955
2950
  externalInputPaths,
1956
2951
  inputHashes: inputSnapshot.hashes,
2952
+ inputSignatures: inputSnapshot.provenSignatures,
1957
2953
  projectDirectories: inputSnapshot.projectDirectories,
1958
2954
  projectSnapshotComplete: false,
1959
2955
  projectRoot,
@@ -1967,23 +2963,49 @@ async function transformProject(props) {
1967
2963
  const externalInputSnapshot = captureExternalInputSnapshot(cached, externalInputPaths);
1968
2964
  cached.externalInputHashes = externalInputSnapshot.hashes;
1969
2965
  cached.externalInputRealpaths = externalInputSnapshot.realpaths;
1970
- stableProjectSnapshot =
1971
- stableProjectSnapshot &&
1972
- matchesCompilerGraphInputProofs(cached) &&
1973
- externalInputSnapshot.complete &&
1974
- captureUniversalHostInputValidation(cached, props.currentFile) !==
1975
- undefined;
2966
+ cached.externalInputSignatures = externalInputSnapshot.signatures;
2967
+ // Evaluate every half, rather than short-circuiting, so a generation that
2968
+ // cannot be reused can say which evidence it lacked. The extra work runs
2969
+ // only on the failing path, where the alternative is recompiling the whole
2970
+ // project for every remaining module.
2971
+ const graphProofs = matchesCompilerGraphInputProofs(cached);
2972
+ const universalInputs = captureUniversalHostInputValidation(cached, props.currentFile) !==
2973
+ undefined;
2974
+ const stableProjectSnapshot = walkStable &&
2975
+ graphProofs &&
2976
+ externalInputSnapshot.complete &&
2977
+ universalInputs;
2978
+ // Only a caching host loses anything here: without a cache every delivery
2979
+ // compiles by design, so an unprovable generation costs it nothing.
2980
+ if (!stableProjectSnapshot && props.trackProjectMembership) {
2981
+ reportUnreusableGeneration(cached, {
2982
+ externalInputs: externalInputSnapshot.complete,
2983
+ graphProofs,
2984
+ universalInputs,
2985
+ walkStable,
2986
+ });
2987
+ }
1976
2988
  cached.projectSnapshotComplete = stableProjectSnapshot;
1977
- if (stableProjectSnapshot && tracker !== undefined) {
2989
+ // Attach notifications only while they can actually prove membership. A
2990
+ // generation that could not open its watchers keeps its recorded snapshot
2991
+ // and validates through it, rather than losing the cache entirely.
2992
+ const notifying = stableProjectSnapshot && notificationsAvailable;
2993
+ if (notifying && tracker !== undefined) {
1978
2994
  cached.projectMutationTracker = tracker;
1979
2995
  }
1980
- if (stableProjectSnapshot && hostInputTracker !== undefined) {
2996
+ if (notifying && hostInputTracker !== undefined) {
1981
2997
  cached.hostInputMutationTracker = hostInputTracker;
1982
2998
  }
1983
- retainTracker =
1984
- stableProjectSnapshot &&
1985
- tracker !== undefined &&
1986
- hostInputTracker !== undefined;
2999
+ if (notifying && candidateTracker !== undefined) {
3000
+ cached.candidateMutationTracker = candidateTracker;
3001
+ }
3002
+ // Every tracker the generation published is retained, and every tracker it
3003
+ // did not is closed below. Naming only two of the three would close a
3004
+ // published candidate tracker the moment either of the others was absent,
3005
+ // and that is the one tracker whose silence is read as evidence.
3006
+ retainTracker = notifying && tracker !== undefined;
3007
+ retainHostInputTracker = notifying && hostInputTracker !== undefined;
3008
+ retainCandidateTracker = notifying && candidateTracker !== undefined;
1987
3009
  return cached;
1988
3010
  }
1989
3011
  finally {
@@ -1994,12 +3016,19 @@ async function transformProject(props) {
1994
3016
  }
1995
3017
  finally {
1996
3018
  try {
1997
- if (!retainTracker && hostInputTracker !== undefined) {
3019
+ if (!retainHostInputTracker && hostInputTracker !== undefined) {
1998
3020
  hostInputTracker.close();
1999
3021
  }
2000
3022
  }
2001
3023
  finally {
2002
- fs.rmSync(scratchDirectory, { force: true, recursive: true });
3024
+ try {
3025
+ if (!retainCandidateTracker && candidateTracker !== undefined) {
3026
+ candidateTracker.close();
3027
+ }
3028
+ }
3029
+ finally {
3030
+ fs.rmSync(scratchDirectory, { force: true, recursive: true });
3031
+ }
2003
3032
  }
2004
3033
  }
2005
3034
  }