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