@ttsc/unplugin 0.30.2 → 0.30.4

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.
@@ -129,6 +129,8 @@ interface TtscProjectMutationTracker {
129
129
  * whole, which cannot answer for one name.
130
130
  */
131
131
  covered?: ReadonlySet<string>;
132
+ /** Whether this is the repository-owned backend with content-event coverage. */
133
+ contentAuthoritative?: boolean;
132
134
  /**
133
135
  * Wait until every event this tracker's watcher has already dispatched has
134
136
  * been applied to it.
@@ -142,6 +144,8 @@ interface TtscProjectMutationTracker {
142
144
  drain?: () => Promise<void>;
143
145
  failed: boolean;
144
146
  membershipChanged: boolean;
147
+ /** Compare event and input paths through this tracker's filesystem identity. */
148
+ overlaps?: (input: string, changed: string) => boolean;
145
149
  settle?: Promise<void>;
146
150
  }
147
151
 
@@ -239,14 +243,13 @@ class TtscUnstableGenerationError extends TtscTerminalGenerationError {
239
243
  /**
240
244
  * A compile this pass already attempted, whose envelope failed outright.
241
245
  *
242
- * The envelope cannot say whether the host reported diagnostics about the
243
- * project or failed to run at all: an ordinary type error arrives as an
244
- * `"exception"` carrying the compiler's own diagnostic text, exactly as a
245
- * crashed host would. Sniffing that message to tell the two apart would be a
246
- * guess, so the adapter uses the one boundary it genuinely owns. Inside a pass
247
- * the answer is already settled, so every later module replays it instead of
248
- * repeating a whole-project transform to reach the same verdict, which is what
249
- * made a single broken save cost one compile per delivered module
246
+ * Native project diagnostics carry a structured failure envelope; setup and
247
+ * host failures can still arrive as opaque exceptions. Both settle the current
248
+ * attempt, so the adapter uses the delivery boundary it owns rather than
249
+ * guessing retryability from a diagnostic message. Inside a pass the answer is
250
+ * already settled, so every later module replays it instead of repeating a
251
+ * whole-project transform to reach the same verdict, which is what made a
252
+ * single broken save cost one compile per delivered module
250
253
  * (samchon/ttsc#1303).
251
254
  *
252
255
  * The scope is exactly the pass. A host whose `buildStart` repeats drops the
@@ -316,6 +319,9 @@ const MAX_GENERATION_PROOF_FAILURES = 8;
316
319
  /** Maximum exact mutation paths kept after a tracker already proved a change. */
317
320
  const MAX_GENERATION_MUTATION_PATHS = 8;
318
321
 
322
+ /** Maximum unrelated roots watched before snapshot validation takes over. */
323
+ const MAX_HOST_INPUT_WATCH_SCOPES = 16;
324
+
319
325
  /** One retry absorbs a transient watch write without admitting an infinite loop. */
320
326
  const TRANSFORM_GENERATION_ATTEMPTS = 2;
321
327
 
@@ -563,6 +569,7 @@ export interface TtscTransformFilesystemOperations {
563
569
  directory: string,
564
570
  listener: (eventType: string, filename: string | null) => void,
565
571
  onError: () => void,
572
+ recursive?: boolean,
566
573
  ): { close: () => void };
567
574
  }
568
575
 
@@ -811,10 +818,12 @@ export interface TtscTransformHooks {
811
818
  addWatchFile?: (file: string, evidence?: TtscWatchInputEvidence) => void;
812
819
  /**
813
820
  * Batched form of {@link addWatchFile}. When supplied, the transform calls it
814
- * once per delivered module and does not call `addWatchFile` for that
815
- * module.
821
+ * once per delivered module and does not call `addWatchFile` for that module.
822
+ * `failed` marks a recovery batch: a failed compiler can omit inputs from its
823
+ * previous successful result, so replacing hosts should retain those
824
+ * spellings until the next successful delivery.
816
825
  */
817
- addWatchFiles?: (inputs: readonly TtscWatchInput[]) => void;
826
+ addWatchFiles?: (inputs: readonly TtscWatchInput[], failed?: boolean) => void;
818
827
  /**
819
828
  * Invoked when the plugin declared the transformed file volatile (the
820
829
  * envelope's `volatile` list): its output depends on non-file inputs that no
@@ -985,6 +994,11 @@ export async function transformTtsc(
985
994
  deliveryEpoch: epoch,
986
995
  filesystem,
987
996
  plugins: options.plugins,
997
+ // One bounded recursive project observer witnesses content restored
998
+ // during the compile itself. Build-scoped adapters close it with the
999
+ // attempt; persistent adapters retain it to make later validations
1000
+ // constant-cost while the generation remains live.
1001
+ retainProjectMembership: cache !== undefined && epoch === undefined,
988
1002
  trackProjectMembership: cache !== undefined,
989
1003
  tsconfig,
990
1004
  });
@@ -1317,10 +1331,14 @@ interface TtscHostInputValidation {
1317
1331
  {
1318
1332
  path: string;
1319
1333
  /**
1320
- * Whether the recorded state of this input came from reading its bytes.
1321
- * An input that existed but could not be read records a missing state, so
1322
- * no signature may stand in for it: its metadata holds still while the
1323
- * bytes behind it appear.
1334
+ * Whether the recorded state of this input has been matched to readable
1335
+ * bytes. Capture sets it immediately for compiler-proven inputs; a
1336
+ * current module supplied from an editor buffer can earn it later after
1337
+ * its disk bytes match the recorded source.
1338
+ *
1339
+ * An input that still cannot be read records a missing state, so no
1340
+ * signature may stand in for it: its metadata holds still while the bytes
1341
+ * behind it appear.
1324
1342
  */
1325
1343
  readable: boolean;
1326
1344
  realpath: string | null;
@@ -1947,11 +1965,11 @@ function collectDeclaredIdentities(
1947
1965
  * invalidated, and the error stays on screen (samchon/ttsc#1312).
1948
1966
  *
1949
1967
  * A failure envelope can retain exact external input spellings from its graph
1950
- * and host metadata. A pre-transform typecheck failure may have no graph yet;
1951
- * its structured diagnostics, or the host's standard diagnostic lines when it
1952
- * could return only an exception, still name the external files that need a
1953
- * repair. The cost is paid only on a failure, and only until the next compile
1954
- * succeeds and narrows the set back to the derived inputs.
1968
+ * and host metadata, including missing resolution candidates on native
1969
+ * typecheck failures. For hosts that return no graph, structured diagnostics or
1970
+ * standard diagnostic lines provide only the paths they actually name. The cost
1971
+ * is paid only on a failure, and only until the next compile succeeds and
1972
+ * narrows the set back to the derived inputs.
1955
1973
  */
1956
1974
  function notifyFailedGenerationInputs(
1957
1975
  hooks: TtscTransformHooks | undefined,
@@ -2002,7 +2020,7 @@ function notifyFailedGenerationInputs(
2002
2020
  }
2003
2021
  }
2004
2022
  if (addWatchFiles !== undefined) {
2005
- addWatchFiles(inputs);
2023
+ addWatchFiles(inputs, true);
2006
2024
  return;
2007
2025
  }
2008
2026
  for (const input of inputs) {
@@ -2749,6 +2767,28 @@ function matchesNarrowPersistentInputs(
2749
2767
  return true;
2750
2768
  }
2751
2769
 
2770
+ /** Whether a healthy notification scope proves one exact input unchanged. */
2771
+ function trackerProvesInputUnchanged(
2772
+ tracker: TtscProjectMutationTracker | undefined,
2773
+ input: string,
2774
+ ): boolean {
2775
+ if (tracker === undefined || tracker.failed || tracker.changesOmitted) {
2776
+ return false;
2777
+ }
2778
+ if (tracker.contentAuthoritative !== true) return false;
2779
+ const absolute = path.resolve(input);
2780
+ if (tracker.covered?.has(absolute) !== true) return false;
2781
+ for (const changed of tracker.changes) {
2782
+ if (
2783
+ tracker.overlaps?.(absolute, changed) ??
2784
+ (pathIsWithin(absolute, changed) || pathIsWithin(changed, absolute))
2785
+ ) {
2786
+ return false;
2787
+ }
2788
+ }
2789
+ return true;
2790
+ }
2791
+
2752
2792
  /**
2753
2793
  * Validate one derived input against the generation, skipping the content read
2754
2794
  * while the recorded metadata signature still holds and its freshly minted
@@ -2776,6 +2816,12 @@ function matchesProvenInput(
2776
2816
  state: TtscEnvelopeDerivation,
2777
2817
  input: string,
2778
2818
  ): boolean {
2819
+ if (
2820
+ trackerProvesInputUnchanged(cached.projectMutationTracker, input) ||
2821
+ trackerProvesInputUnchanged(cached.hostInputMutationTracker, input)
2822
+ ) {
2823
+ return true;
2824
+ }
2779
2825
  const observation = cached.externalInputObservations?.[path.resolve(input)];
2780
2826
  if (observation !== undefined) {
2781
2827
  if (
@@ -2785,7 +2831,25 @@ function matchesProvenInput(
2785
2831
  ) {
2786
2832
  return true;
2787
2833
  }
2788
- return matchesRecordedInput(cached, input);
2834
+ const filesystem = resultFilesystem(cached.result);
2835
+ const spelling = path.resolve(input);
2836
+ const before = inputMetadataEvidence(input, filesystem);
2837
+ if (
2838
+ before !== undefined &&
2839
+ before.separable &&
2840
+ cached.externalInputSignatures?.[spelling] === before.signature
2841
+ ) {
2842
+ return true;
2843
+ }
2844
+ if (!matchesRecordedInput(cached, input)) return false;
2845
+ const after = inputMetadataSignature(input, filesystem);
2846
+ const signatures = (cached.externalInputSignatures ??= {});
2847
+ if (before?.separable === true && before.signature === after) {
2848
+ signatures[spelling] = after;
2849
+ } else {
2850
+ delete signatures[spelling];
2851
+ }
2852
+ return true;
2789
2853
  }
2790
2854
  const slot = inputSignatureSlot(cached, state, input);
2791
2855
  if (slot === undefined) {
@@ -2839,12 +2903,7 @@ function notifiesAbsence(
2839
2903
  cached: TtscCachedProjectTransform,
2840
2904
  input: string,
2841
2905
  ): boolean {
2842
- const tracker = cached.candidateMutationTracker;
2843
- return (
2844
- tracker !== undefined &&
2845
- !tracker.failed &&
2846
- tracker.covered?.has(path.resolve(input)) === true
2847
- );
2906
+ return trackerProvesInputUnchanged(cached.candidateMutationTracker, input);
2848
2907
  }
2849
2908
 
2850
2909
  /**
@@ -2905,6 +2964,14 @@ function matchesUniversalHostInputs(
2905
2964
  cached: TtscCachedProjectTransform,
2906
2965
  validation: TtscHostInputValidation,
2907
2966
  ): boolean {
2967
+ let notificationsProveAll = true;
2968
+ for (const input of validation.covered) {
2969
+ if (!trackerProvesInputUnchanged(cached.hostInputMutationTracker, input)) {
2970
+ notificationsProveAll = false;
2971
+ break;
2972
+ }
2973
+ }
2974
+ if (notificationsProveAll) return true;
2908
2975
  return (
2909
2976
  matchesUniversalHostInputEntries(cached, validation) &&
2910
2977
  matchesUniversalHostInputProbes(cached, validation)
@@ -2939,6 +3006,15 @@ function matchesUniversalHostInputEntries(
2939
3006
  if (!matchesRecordedInput(cached, entry.path)) {
2940
3007
  return false;
2941
3008
  }
3009
+ if (entry.readable === false) {
3010
+ const slot = inputSignatureSlot(
3011
+ cached,
3012
+ envelopeDerivation(cached),
3013
+ entry.path,
3014
+ );
3015
+ entry.readable =
3016
+ slot !== undefined && slot.recorded !== MISSING_INPUT_STATE;
3017
+ }
2942
3018
  if (evidence === undefined) return false;
2943
3019
  // Re-earn the proof under the rules the capture applies: an entry whose
2944
3020
  // recorded state came from reading nothing keeps its content comparison, a
@@ -3177,6 +3253,43 @@ function captureUniversalHostInputValidation(
3177
3253
  return { failures, validation };
3178
3254
  }
3179
3255
 
3256
+ /**
3257
+ * Keep watcher silence as a content proof only for inputs whose bytes capture
3258
+ * actually proved.
3259
+ *
3260
+ * The observers must open before the post-compile snapshots so they can witness
3261
+ * an A-B-A race. Their exact coverage can only be narrowed after those reads.
3262
+ * Existing-but-unreadable and absent inputs record the same `missing` state;
3263
+ * neither may inherit a content shortcut because its metadata can stay fixed
3264
+ * while bytes become readable. Missing resolver candidates inside the project
3265
+ * retain their separate component-aware candidate tracker, while every other
3266
+ * such input continues through the recorded predicate or directory-list proof.
3267
+ */
3268
+ function restrictNotificationCoverageToProvenInputs(
3269
+ tracker: TtscProjectMutationTracker | undefined,
3270
+ cached: TtscCachedProjectTransform,
3271
+ hostValidation: TtscHostInputValidation | undefined,
3272
+ ): void {
3273
+ if (!(tracker?.covered instanceof Set)) return;
3274
+ const covered = tracker.covered as Set<string>;
3275
+ const state = envelopeDerivation(cached);
3276
+ for (const input of [...covered]) {
3277
+ const absolute = path.resolve(input);
3278
+ const hostEntry = hostValidation?.entries.get(absolute);
3279
+ if (
3280
+ (hostValidation?.covered.has(absolute) === true &&
3281
+ (hostEntry === undefined ||
3282
+ (hostEntry.readable === false && hostEntry.strict !== true))) ||
3283
+ cached.externalInputHashes?.[derivationIdentity(state, absolute)] ===
3284
+ MISSING_INPUT_STATE ||
3285
+ cached.externalInputObservations?.[absolute]?.readFile?.ok === false ||
3286
+ cached.externalInputObservations?.[absolute]?.fileExists === false
3287
+ ) {
3288
+ covered.delete(input);
3289
+ }
3290
+ }
3291
+ }
3292
+
3180
3293
  /**
3181
3294
  * The recorded state of an input the generation read nothing from: absent, or
3182
3295
  * present but unreadable. It is deliberately not a hash, so no signature may
@@ -3313,6 +3426,8 @@ function disposeFilesystemClockReference(referenceDirectory: string): void {
3313
3426
  interface TtscInputMetadataEvidence {
3314
3427
  /** The joined metadata signature of the lexical path and its link target. */
3315
3428
  signature: string;
3429
+ /** Whether a directory observer can account for every content mutation. */
3430
+ notificationAuthoritative: boolean;
3316
3431
  /**
3317
3432
  * Whether a later write is guaranteed to move this signature. Only a
3318
3433
  * signature captured with this evidence may be recorded to stand in for a
@@ -3358,6 +3473,7 @@ function inputMetadataEvidence(
3358
3473
  link.ctimeNs,
3359
3474
  "missing-target",
3360
3475
  ].join(":"),
3476
+ notificationAuthoritative: false,
3361
3477
  separable: false,
3362
3478
  };
3363
3479
  }
@@ -3366,17 +3482,26 @@ function inputMetadataEvidence(
3366
3482
  signature: [
3367
3483
  link.dev,
3368
3484
  link.ino,
3485
+ link.nlink,
3369
3486
  link.mode,
3370
3487
  link.size,
3371
3488
  link.mtimeNs,
3372
3489
  link.ctimeNs,
3373
3490
  target.dev,
3374
3491
  target.ino,
3492
+ target.nlink,
3375
3493
  target.mode,
3376
3494
  target.size,
3377
3495
  target.mtimeNs,
3378
3496
  target.ctimeNs,
3379
3497
  ].join(":"),
3498
+ // A write through a hardlink outside the watched tree changes this inode
3499
+ // without notifying either the original directory or a watcher opened on
3500
+ // the original path. Keep such files on metadata validation. Symlinks are
3501
+ // likewise governed by the target path, outside the lexical observer.
3502
+ notificationAuthoritative:
3503
+ !link.isSymbolicLink() &&
3504
+ !(link.isFile() && (link.nlink > 1n || target.nlink > 1n)),
3380
3505
  // Both halves must be separable: a write remints the target's stamp, a
3381
3506
  // link retarget the link's own, and either one hiding inside its recorded
3382
3507
  // tick would evade the skipped content and realpath comparisons.
@@ -3902,12 +4027,14 @@ function captureExternalInputSnapshot(
3902
4027
  });
3903
4028
  continue;
3904
4029
  }
4030
+ const before = inputMetadataEvidence(input, filesystem);
3905
4031
  const mismatches = graphInputObservationFailures(
3906
4032
  input,
3907
4033
  predicateObservation,
3908
4034
  filesystem,
3909
4035
  state.identityContext,
3910
4036
  );
4037
+ const after = inputMetadataSignature(input, filesystem);
3911
4038
  if (mismatches.length !== 0) complete = false;
3912
4039
  for (const kind of mismatches) {
3913
4040
  recordGenerationProofFailure(failures, {
@@ -3916,6 +4043,7 @@ function captureExternalInputSnapshot(
3916
4043
  path: input,
3917
4044
  });
3918
4045
  }
4046
+ if (mismatches.length === 0) record(input, before, after);
3919
4047
  observations[spelling] = predicateObservation;
3920
4048
  continue;
3921
4049
  }
@@ -4291,12 +4419,14 @@ function collectProjectInputSnapshot(
4291
4419
  directoryComplete: boolean;
4292
4420
  fileSignatures: Record<string, string>;
4293
4421
  hashes: Record<string, string>;
4422
+ notificationUnsafeInputs: Set<string>;
4294
4423
  projectDirectories: TtscProjectDirectorySnapshot[];
4295
4424
  provenSignatures: Record<string, string>;
4296
4425
  unstableFiles: Set<string>;
4297
4426
  walkFailures: TtscProjectWalkFailure[];
4298
4427
  } {
4299
4428
  const hashes: Record<string, string> = {};
4429
+ const notificationUnsafeInputs = new Set<string>();
4300
4430
  const fileSignatures: Record<string, string> = {};
4301
4431
  const provenSignatures: Record<string, string> = {};
4302
4432
  const unstableFiles = new Set<string>();
@@ -4321,6 +4451,9 @@ function collectProjectInputSnapshot(
4321
4451
  continue;
4322
4452
  }
4323
4453
  const before = inputMetadataEvidence(file, filesystem);
4454
+ if (before?.notificationAuthoritative !== true) {
4455
+ notificationUnsafeInputs.add(key);
4456
+ }
4324
4457
  // A file whose signature still equals the one captured around the read
4325
4458
  // that produced the recorded hash carries that content, so the whole
4326
4459
  // project does not have to be re-read to prove one delivery. Recheck the
@@ -4339,24 +4472,27 @@ function collectProjectInputSnapshot(
4339
4472
  continue;
4340
4473
  }
4341
4474
  const contents = filesystem.readFile(file);
4342
- const after = inputMetadataSignature(file, filesystem);
4475
+ const after = inputMetadataEvidence(file, filesystem);
4476
+ if (after?.notificationAuthoritative !== true) {
4477
+ notificationUnsafeInputs.add(key);
4478
+ }
4343
4479
  hashes[key] = hashText(contents);
4344
4480
  if (
4345
4481
  before === undefined ||
4346
4482
  after === undefined ||
4347
- before.signature !== after
4483
+ before.signature !== after.signature
4348
4484
  ) {
4349
4485
  complete = false;
4350
4486
  unstableFiles.add(key);
4351
4487
  walkFailures.push({ kind: "file-changed-during-read", path: file });
4352
4488
  } else {
4353
- fileSignatures[key] = after;
4489
+ fileSignatures[key] = after.signature;
4354
4490
  // Only a signature whose stamp's tick the filesystem's clock provably
4355
4491
  // left before this read may later stand in for the content comparison
4356
4492
  // ({@link stampSeparable}); the raw signature above still participates
4357
4493
  // in the generation-time stability comparison.
4358
4494
  if (before.separable) {
4359
- provenSignatures[key] = after;
4495
+ provenSignatures[key] = after.signature;
4360
4496
  }
4361
4497
  }
4362
4498
  } catch {
@@ -4378,6 +4514,7 @@ function collectProjectInputSnapshot(
4378
4514
  directoryComplete: walked.complete && attributed,
4379
4515
  fileSignatures,
4380
4516
  hashes,
4517
+ notificationUnsafeInputs,
4381
4518
  projectDirectories: walked.directories,
4382
4519
  provenSignatures,
4383
4520
  unstableFiles,
@@ -4618,13 +4755,14 @@ function openDirectoryWatch(
4618
4755
  directory: string,
4619
4756
  listener: (eventType: string, filename: string | null) => void,
4620
4757
  onError: () => void,
4758
+ recursive = false,
4621
4759
  ): { close: () => void } {
4622
4760
  if (filesystem.watch !== undefined) {
4623
- return filesystem.watch(directory, listener, onError);
4761
+ return filesystem.watch(directory, listener, onError, recursive);
4624
4762
  }
4625
4763
  const watcher = fs.watch(
4626
4764
  directory,
4627
- { persistent: false },
4765
+ { persistent: false, recursive },
4628
4766
  (eventType, filename) =>
4629
4767
  listener(eventType, filename === null ? null : String(filename)),
4630
4768
  );
@@ -4653,140 +4791,289 @@ function closeDirectoryWatches(watchers: { close: () => void }[]): void {
4653
4791
  /** Watch every walked directory for membership changes after generation. */
4654
4792
  async function createProjectMutationTracker(
4655
4793
  directories: readonly TtscProjectDirectorySnapshot[],
4794
+ covered: ReadonlySet<string>,
4656
4795
  filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
4657
4796
  policy: ITtscProjectMembershipPolicy = PERMISSIVE_PROJECT_MEMBERSHIP_POLICY,
4658
4797
  ): Promise<TtscProjectMutationTracker> {
4798
+ const identities = createHostPathIdentityContext(filesystem);
4799
+ const root = commonDirectoryRoot(
4800
+ directories.map((directory) => directory.path),
4801
+ );
4802
+ const authoritative =
4803
+ root !== undefined &&
4804
+ filesystem.watch === undefined &&
4805
+ pathTraversesSymbolicLink(
4806
+ path.join(root, ".ttsc-notification-authority"),
4807
+ filesystem,
4808
+ new Map(),
4809
+ )
4810
+ ? new Set<string>()
4811
+ : covered;
4659
4812
  const tracker: TtscProjectMutationTracker = {
4660
4813
  changes: new Set(),
4661
4814
  changesOmitted: false,
4662
- close: () => undefined,
4815
+ close: () => {
4816
+ tracker.failed = true;
4817
+ },
4818
+ covered: authoritative,
4663
4819
  failed: false,
4664
4820
  membershipChanged: false,
4821
+ overlaps: (input, changed) =>
4822
+ identities.isWithin(input, changed) ||
4823
+ identities.isWithin(changed, input),
4824
+ contentAuthoritative: filesystem.watch === undefined,
4825
+ };
4826
+ if (root === undefined) return tracker;
4827
+ const knownDirectories = new Set(
4828
+ directories
4829
+ .filter((directory) => directory.relevant)
4830
+ .map((directory) => path.resolve(directory.path)),
4831
+ );
4832
+ const reportsMembership = (location: string, filename: string): boolean => {
4833
+ const changed = path.join(location, filename);
4834
+ return (
4835
+ knownDirectories.has(path.resolve(changed)) ||
4836
+ reportsProgramMembership(
4837
+ changed,
4838
+ path.basename(filename),
4839
+ policy,
4840
+ filesystem,
4841
+ )
4842
+ );
4843
+ };
4844
+ const reportsNewMembership = (
4845
+ location: string,
4846
+ filename: string,
4847
+ ): boolean => {
4848
+ const changed = path.resolve(location, filename);
4849
+ return (
4850
+ !knownDirectories.has(changed) && tracker.covered?.has(changed) !== true
4851
+ );
4665
4852
  };
4666
4853
  if (process.platform === "win32" && filesystem.watch === undefined) {
4667
4854
  await registerWindowsProjectMutationTracker(
4668
4855
  tracker,
4669
- directories.map((directory) => ({ directory: directory.path })),
4856
+ [{ directory: root, recursive: true }],
4670
4857
  false,
4671
4858
  filesystem,
4672
- (location, filename) =>
4673
- reportsProgramMembership(
4674
- path.join(location, filename),
4675
- filename,
4676
- policy,
4677
- filesystem,
4678
- ),
4859
+ reportsMembership,
4860
+ (_location, filename) =>
4861
+ isPossibleProgramFileName(path.basename(filename), policy),
4862
+ reportsNewMembership,
4679
4863
  );
4680
4864
  return tracker;
4681
4865
  }
4682
4866
  const watchers: { close: () => void }[] = [];
4683
- tracker.close = () => closeDirectoryWatches(watchers);
4684
- for (const directory of directories) {
4685
- try {
4686
- watchers.push(
4687
- openDirectoryWatch(
4688
- filesystem,
4689
- directory.path,
4690
- (eventType, filename) => {
4691
- if (eventType !== "rename") {
4692
- return;
4693
- }
4694
- if (
4695
- filename !== null &&
4696
- !reportsProgramMembership(
4697
- path.join(directory.path, filename),
4698
- filename,
4699
- policy,
4700
- filesystem,
4701
- )
4702
- ) {
4703
- return;
4704
- }
4705
- recordProjectMutation(
4706
- tracker,
4707
- filename === null
4708
- ? directory.path
4709
- : path.join(directory.path, filename),
4710
- );
4711
- },
4712
- () => {
4713
- tracker.failed = true;
4714
- },
4715
- ),
4716
- );
4717
- } catch {
4718
- tracker.failed = true;
4719
- }
4867
+ tracker.close = () => {
4868
+ tracker.failed = true;
4869
+ closeDirectoryWatches(watchers);
4870
+ };
4871
+ try {
4872
+ watchers.push(
4873
+ openDirectoryWatch(
4874
+ filesystem,
4875
+ root,
4876
+ (eventType, filename) => {
4877
+ const changed = filename === null ? root : path.join(root, filename);
4878
+ const membership =
4879
+ filename === null || reportsMembership(root, filename);
4880
+ if (
4881
+ membership &&
4882
+ (eventType === "rename" ||
4883
+ reportsNewMembership(root, filename ?? ""))
4884
+ ) {
4885
+ recordProjectMutation(tracker, changed);
4886
+ } else if (
4887
+ filename === null ||
4888
+ isPossibleProgramFileName(path.basename(filename), policy)
4889
+ ) {
4890
+ recordProjectChange(tracker, changed);
4891
+ }
4892
+ },
4893
+ () => {
4894
+ tracker.failed = true;
4895
+ },
4896
+ true,
4897
+ ),
4898
+ );
4899
+ } catch {
4900
+ tracker.failed = true;
4720
4901
  }
4721
4902
  return tracker;
4722
4903
  }
4723
4904
 
4905
+ /** Common ancestor owned by every project directory snapshot. */
4906
+ function commonDirectoryRoot(
4907
+ directories: readonly string[],
4908
+ ): string | undefined {
4909
+ if (directories.length === 0) return undefined;
4910
+ let root = path.resolve(directories[0]!);
4911
+ for (const directory of directories.slice(1)) {
4912
+ const absolute = path.resolve(directory);
4913
+ while (!pathIsWithin(absolute, root)) {
4914
+ const parent = path.dirname(root);
4915
+ if (parent === root) return root;
4916
+ root = parent;
4917
+ }
4918
+ }
4919
+ return root;
4920
+ }
4921
+
4724
4922
  /** Watch exact universal inputs, or their nearest existing parent if missing. */
4725
4923
  async function createHostInputMutationTracker(
4726
4924
  inputs: readonly string[],
4727
4925
  filesystem: TtscTransformFilesystemOperations,
4728
4926
  covered: ReadonlySet<string>,
4729
4927
  events: "all" | "rename" = "all",
4928
+ preferredRoot?: string,
4730
4929
  ): Promise<TtscProjectMutationTracker> {
4731
4930
  const identities = createHostPathIdentityContext(filesystem);
4732
- const namesByDirectory = new Map<
4931
+ const linkedAncestors = new Map<string, boolean>();
4932
+ const authoritative = new Set(
4933
+ [...covered]
4934
+ .map((input) => path.resolve(input))
4935
+ .filter((input) => {
4936
+ if (pathTraversesSymbolicLink(input, filesystem, linkedAncestors)) {
4937
+ return false;
4938
+ }
4939
+ try {
4940
+ // A lexical symlink can keep its own directory silent while a target
4941
+ // elsewhere changes or appears. Its joined metadata proof must stay
4942
+ // on the validation path, including while the link is broken.
4943
+ const lexical = filesystem.lstat(input);
4944
+ if (
4945
+ lexical.isSymbolicLink() ||
4946
+ (lexical.isFile() && lexical.nlink > 1n)
4947
+ )
4948
+ return false;
4949
+ } catch {
4950
+ // A genuinely absent lexical path is covered by its nearest existing
4951
+ // ancestor and remains eligible for notification proof.
4952
+ }
4953
+ const target = hostInputRealpath(input, filesystem);
4954
+ return (
4955
+ target === null ||
4956
+ pathIdentityKey(target, identities) ===
4957
+ pathIdentityKey(input, identities)
4958
+ );
4959
+ }),
4960
+ );
4961
+ const locationsByDirectory = new Map<
4733
4962
  string,
4734
- { directory: string; names: Set<string> }
4963
+ {
4964
+ directory: string;
4965
+ names: Set<string>;
4966
+ paths?: Set<string>;
4967
+ recursive?: boolean;
4968
+ }
4735
4969
  >();
4970
+ const internalRoot =
4971
+ preferredRoot === undefined ? undefined : path.resolve(preferredRoot);
4736
4972
  for (const input of inputs) {
4737
4973
  const absolute = path.resolve(input);
4738
4974
  const probe = filesystem.exists(absolute)
4739
4975
  ? { directory: path.dirname(absolute), name: path.basename(absolute) }
4740
4976
  : missingPathProbe(absolute, filesystem);
4741
- const directoryIdentity = identities.resolve(probe.directory);
4742
- let location = namesByDirectory.get(directoryIdentity.key);
4977
+ const internal =
4978
+ internalRoot !== undefined && pathIsWithin(absolute, internalRoot);
4979
+ const directory = internal ? internalRoot : probe.directory;
4980
+ const directoryIdentity = identities.resolve(directory);
4981
+ let location = locationsByDirectory.get(directoryIdentity.key);
4743
4982
  if (location === undefined) {
4744
4983
  location = {
4745
4984
  directory: directoryIdentity.path,
4746
4985
  names: new Set<string>(),
4986
+ ...(internal ? { paths: new Set<string>(), recursive: true } : {}),
4747
4987
  };
4748
- namesByDirectory.set(directoryIdentity.key, location);
4988
+ locationsByDirectory.set(directoryIdentity.key, location);
4989
+ }
4990
+ if (location.paths !== undefined) {
4991
+ location.paths.add(
4992
+ pathIdentityKey(path.resolve(probe.directory, probe.name), identities),
4993
+ );
4994
+ } else {
4995
+ location.names.add(
4996
+ normalizeHostInputName(
4997
+ probe.name,
4998
+ identities.caseSensitive(directoryIdentity.path),
4999
+ ),
5000
+ );
4749
5001
  }
4750
- location.names.add(
4751
- normalizeHostInputName(
4752
- probe.name,
4753
- identities.caseSensitive(directoryIdentity.path),
4754
- ),
4755
- );
4756
5002
  }
4757
- const locations = [...namesByDirectory.values()].map((location) => ({
4758
- directory: location.directory,
4759
- names: [...location.names],
4760
- }));
5003
+ const locations = [...locationsByDirectory.values()];
4761
5004
  const tracker: TtscProjectMutationTracker = {
4762
5005
  changes: new Set(),
4763
5006
  changesOmitted: false,
4764
- close: () => undefined,
5007
+ close: () => {
5008
+ tracker.failed = true;
5009
+ },
4765
5010
  // Coverage is the caller's claim, and it is required rather than derived
4766
5011
  // from the input list: an input is watched by its exact name here, but only
4767
5012
  // the caller knows whether the path leading to it is watched as well, which
4768
5013
  // is what a later validation needs before it trusts the watcher instead of
4769
5014
  // probing the path again. Deriving it here would hand that claim to every
4770
5015
  // future caller by default (samchon/ttsc#1261).
4771
- covered,
5016
+ covered: authoritative,
5017
+ contentAuthoritative: filesystem.watch === undefined,
4772
5018
  failed: false,
4773
5019
  membershipChanged: false,
5020
+ overlaps: (input, changed) =>
5021
+ identities.isWithin(input, changed) ||
5022
+ identities.isWithin(changed, input),
5023
+ };
5024
+ if (locations.length > MAX_HOST_INPUT_WATCH_SCOPES) {
5025
+ // A graph spread over unrelated external roots cannot be folded into one
5026
+ // recursive observer without watching an arbitrarily broad filesystem
5027
+ // ancestor. Decline the notification proof and use the recorded snapshots;
5028
+ // descriptor count must never scale with an adversarial input graph.
5029
+ tracker.failed = true;
5030
+ return tracker;
5031
+ }
5032
+ const matches = (directory: string, filename: string): boolean => {
5033
+ const location = locationsByDirectory.get(
5034
+ identities.resolve(directory).key,
5035
+ );
5036
+ if (location === undefined) return false;
5037
+ if (location.paths !== undefined) {
5038
+ let changed = path.resolve(directory, filename);
5039
+ for (;;) {
5040
+ if (location.paths.has(pathIdentityKey(changed, identities))) {
5041
+ return true;
5042
+ }
5043
+ const parent = path.dirname(changed);
5044
+ if (parent === changed || !pathIsWithin(parent, location.directory)) {
5045
+ return false;
5046
+ }
5047
+ changed = parent;
5048
+ }
5049
+ }
5050
+ return location.names.has(
5051
+ normalizeHostInputName(filename, identities.caseSensitive(directory)),
5052
+ );
4774
5053
  };
4775
5054
  if (process.platform === "win32" && filesystem.watch === undefined) {
4776
5055
  await registerWindowsProjectMutationTracker(
4777
5056
  tracker,
4778
- locations,
5057
+ locations.map((location) => ({
5058
+ directory: location.directory,
5059
+ ...(location.recursive === true
5060
+ ? { recursive: true }
5061
+ : { names: [...location.names] }),
5062
+ })),
4779
5063
  events === "all",
4780
5064
  filesystem,
5065
+ matches,
5066
+ matches,
4781
5067
  );
4782
5068
  return tracker;
4783
5069
  }
4784
5070
  const watchers: { close: () => void }[] = [];
4785
- tracker.close = () => closeDirectoryWatches(watchers);
5071
+ tracker.close = () => {
5072
+ tracker.failed = true;
5073
+ closeDirectoryWatches(watchers);
5074
+ };
4786
5075
  for (const location of locations) {
4787
5076
  try {
4788
- const names = new Set(location.names);
4789
- const caseSensitive = identities.caseSensitive(location.directory);
4790
5077
  watchers.push(
4791
5078
  openDirectoryWatch(
4792
5079
  filesystem,
@@ -4795,22 +5082,22 @@ async function createHostInputMutationTracker(
4795
5082
  if (events === "rename" && eventType !== "rename") {
4796
5083
  return;
4797
5084
  }
4798
- const reported =
4799
- filename === null
4800
- ? null
4801
- : normalizeHostInputName(filename, caseSensitive);
4802
- if (reported === null || names.has(reported)) {
4803
- recordProjectMutation(
4804
- tracker,
5085
+ if (filename === null || matches(location.directory, filename)) {
5086
+ const changed =
4805
5087
  filename === null
4806
5088
  ? location.directory
4807
- : path.join(location.directory, filename),
4808
- );
5089
+ : path.join(location.directory, filename);
5090
+ if (eventType === "rename") {
5091
+ recordProjectMutation(tracker, changed);
5092
+ } else {
5093
+ recordProjectChange(tracker, changed);
5094
+ }
4809
5095
  }
4810
5096
  },
4811
5097
  () => {
4812
5098
  tracker.failed = true;
4813
5099
  },
5100
+ location.recursive === true,
4814
5101
  ),
4815
5102
  );
4816
5103
  } catch {
@@ -4820,6 +5107,47 @@ async function createHostInputMutationTracker(
4820
5107
  return tracker;
4821
5108
  }
4822
5109
 
5110
+ /**
5111
+ * Whether any lexical component above an input is a symbolic link or junction.
5112
+ *
5113
+ * A recursive observer follows the component to its current physical target.
5114
+ * Retargeting it can therefore move the input without producing an event on the
5115
+ * old target. Keep those spellings on metadata/realpath validation. The memo
5116
+ * makes this one lstat per distinct directory component for the generation,
5117
+ * rather than one walk per graph input.
5118
+ */
5119
+ function pathTraversesSymbolicLink(
5120
+ input: string,
5121
+ filesystem: TtscTransformFilesystemOperations,
5122
+ memo: Map<string, boolean>,
5123
+ ): boolean {
5124
+ const visited: string[] = [];
5125
+ let current = path.dirname(path.resolve(input));
5126
+ let linked = false;
5127
+ for (;;) {
5128
+ const known = memo.get(current);
5129
+ if (known !== undefined) {
5130
+ linked = known;
5131
+ break;
5132
+ }
5133
+ visited.push(current);
5134
+ try {
5135
+ if (filesystem.lstat(current).isSymbolicLink()) {
5136
+ linked = true;
5137
+ break;
5138
+ }
5139
+ } catch {
5140
+ // A missing component is not a link. Its nearest existing ancestor will
5141
+ // still be inspected before the walk reaches the volume root.
5142
+ }
5143
+ const parent = path.dirname(current);
5144
+ if (parent === current) break;
5145
+ current = parent;
5146
+ }
5147
+ for (const directory of visited) memo.set(directory, linked);
5148
+ return linked;
5149
+ }
5150
+
4823
5151
  /**
4824
5152
  * Whether a path lies inside a directory the configuration excludes.
4825
5153
  *
@@ -4914,6 +5242,14 @@ function recordProjectMutation(
4914
5242
  changed: string,
4915
5243
  ): void {
4916
5244
  tracker.membershipChanged = true;
5245
+ recordProjectChange(tracker, changed);
5246
+ }
5247
+
5248
+ /** Record a content event without classifying it as a membership change. */
5249
+ function recordProjectChange(
5250
+ tracker: TtscProjectMutationTracker,
5251
+ changed: string,
5252
+ ): void {
4917
5253
  if (tracker.changes.has(changed)) return;
4918
5254
  if (tracker.changes.size < MAX_GENERATION_MUTATION_PATHS) {
4919
5255
  tracker.changes.add(changed);
@@ -4941,6 +5277,10 @@ interface WindowsProjectMutationBroker {
4941
5277
  * have already narrowed theirs by construction.
4942
5278
  */
4943
5279
  membership?: (location: string, filename: string) => boolean;
5280
+ /** Whether one named event can change compiler-consumed content. */
5281
+ content?: (location: string, filename: string) => boolean;
5282
+ /** Classify a backend `change` that can add one unknown program path. */
5283
+ changeAddsMembership?: (location: string, filename: string) => boolean;
4944
5284
  ready: () => void;
4945
5285
  /**
4946
5286
  * The walk's own spelling for each canonical directory the child watches,
@@ -4962,6 +5302,7 @@ let windowsProjectMutationBroker: WindowsProjectMutationBroker | undefined;
4962
5302
  interface WindowsMutationLocation {
4963
5303
  directory: string;
4964
5304
  names?: string[];
5305
+ recursive?: boolean;
4965
5306
  }
4966
5307
 
4967
5308
  /**
@@ -4982,6 +5323,8 @@ async function registerWindowsProjectMutationTracker(
4982
5323
  * name-watching trackers pass none, since they already watch exact names.
4983
5324
  */
4984
5325
  membership?: (location: string, filename: string) => boolean,
5326
+ content?: (location: string, filename: string) => boolean,
5327
+ changeAddsMembership?: (location: string, filename: string) => boolean,
4985
5328
  ): Promise<void> {
4986
5329
  const broker = getWindowsProjectMutationBroker();
4987
5330
  // The child watches canonical directories, and reports its events under that
@@ -5002,24 +5345,30 @@ async function registerWindowsProjectMutationTracker(
5002
5345
  return {
5003
5346
  directory,
5004
5347
  ...(location.names === undefined ? {} : { names: location.names }),
5348
+ ...(location.recursive === true ? { recursive: true } : {}),
5005
5349
  };
5006
5350
  });
5007
5351
  broker.pendingRegistrations += 1;
5008
5352
  broker.child.ref();
5009
- broker.child.channel?.ref();
5353
+ // Bun's IPC channel omits Node's Control.ref/unref methods. The child itself
5354
+ // still owns the outstanding acknowledgement on that runtime.
5355
+ broker.child.channel?.ref?.();
5010
5356
  const id = broker.nextId++;
5011
5357
  let resolveReady!: () => void;
5012
5358
  const ready = new Promise<void>((resolve) => {
5013
5359
  resolveReady = resolve;
5014
5360
  });
5015
5361
  broker.trackers.set(id, {
5016
- membership,
5362
+ ...(changeAddsMembership === undefined ? {} : { changeAddsMembership }),
5363
+ ...(content === undefined ? {} : { content }),
5364
+ ...(membership === undefined ? {} : { membership }),
5017
5365
  ready: resolveReady,
5018
5366
  spellings,
5019
5367
  tracker,
5020
5368
  });
5021
5369
  tracker.drain = () => drainWindowsProjectMutationBroker(broker);
5022
5370
  tracker.close = () => {
5371
+ tracker.failed = true;
5023
5372
  const active = broker.trackers.get(id);
5024
5373
  if (active === undefined) return;
5025
5374
  broker.trackers.delete(id);
@@ -5071,7 +5420,7 @@ async function registerWindowsProjectMutationTracker(
5071
5420
  // exit mid-build.
5072
5421
  if (broker.pendingRegistrations === 0 && broker.pendingDrains === 0) {
5073
5422
  broker.child.unref();
5074
- broker.child.channel?.unref();
5423
+ broker.child.channel?.unref?.();
5075
5424
  }
5076
5425
  }
5077
5426
  }
@@ -5116,6 +5465,7 @@ function getWindowsProjectMutationBroker(): WindowsProjectMutationBroker {
5116
5465
  drained?: boolean;
5117
5466
  failed?: boolean;
5118
5467
  filename?: string | null;
5468
+ eventType?: string;
5119
5469
  id?: number;
5120
5470
  ready?: boolean;
5121
5471
  };
@@ -5138,19 +5488,33 @@ function getWindowsProjectMutationBroker(): WindowsProjectMutationBroker {
5138
5488
  // comparison and every recorded witness downstream expects.
5139
5489
  const reported =
5140
5490
  registration.spellings.get(record.directory) ?? record.directory;
5491
+ const changed =
5492
+ typeof record.filename === "string"
5493
+ ? path.join(reported, record.filename)
5494
+ : reported;
5141
5495
  if (
5142
5496
  typeof record.filename === "string" &&
5143
- registration.membership !== undefined &&
5144
- !registration.membership(reported, record.filename)
5497
+ registration.membership !== undefined
5145
5498
  ) {
5499
+ const membership = registration.membership(reported, record.filename);
5500
+ if (
5501
+ membership &&
5502
+ (record.eventType === "rename" ||
5503
+ registration.changeAddsMembership?.(reported, record.filename) ===
5504
+ true)
5505
+ ) {
5506
+ recordProjectMutation(registration.tracker, changed);
5507
+ return;
5508
+ }
5509
+ if (
5510
+ record.eventType !== "rename" &&
5511
+ registration.content?.(reported, record.filename) === true
5512
+ ) {
5513
+ recordProjectChange(registration.tracker, changed);
5514
+ }
5146
5515
  return;
5147
5516
  }
5148
- recordProjectMutation(
5149
- registration.tracker,
5150
- typeof record.filename === "string"
5151
- ? path.join(reported, record.filename)
5152
- : reported,
5153
- );
5517
+ recordProjectMutation(registration.tracker, changed);
5154
5518
  } else {
5155
5519
  registration.tracker.membershipChanged = true;
5156
5520
  }
@@ -5199,7 +5563,7 @@ function startWindowsProjectMutationDrain(
5199
5563
  broker.pendingDrains -= 1;
5200
5564
  if (broker.pendingDrains === 0 && broker.pendingRegistrations === 0) {
5201
5565
  broker.child.unref();
5202
- broker.child.channel?.unref();
5566
+ broker.child.channel?.unref?.();
5203
5567
  }
5204
5568
  resolve();
5205
5569
  };
@@ -5210,7 +5574,7 @@ function startWindowsProjectMutationDrain(
5210
5574
  // process exits mid-build with nothing to report.
5211
5575
  broker.pendingDrains += 1;
5212
5576
  broker.child.ref();
5213
- broker.child.channel?.ref();
5577
+ broker.child.channel?.ref?.();
5214
5578
  const timer = setTimeout(release, WINDOWS_MUTATION_DRAIN_FALLBACK_MS);
5215
5579
  broker.drains.set(id, release);
5216
5580
  if (broker.child.send?.({ id, op: "drain" }) !== true) {
@@ -5242,9 +5606,9 @@ const WINDOWS_WATCH_BROKER_SOURCE = [
5242
5606
  " for (const location of message.locations) {",
5243
5607
  " try {",
5244
5608
  " const names = location.names === undefined ? undefined : new Set(location.names.map((name) => name.toLowerCase()));",
5245
- " const watcher = fs.watch(location.directory, { persistent: false }, (event, filename) => {",
5609
+ " const watcher = fs.watch(location.directory, { persistent: false, recursive: location.recursive === true }, (event, filename) => {",
5246
5610
  " const matches = names === undefined || filename === null || names.has(String(filename).toLowerCase());",
5247
- ' if (matches && (message.allEvents || event === "rename")) process.send?.({ directory: location.directory, filename: filename === null ? null : String(filename), id: message.id });',
5611
+ ' if (matches && (message.allEvents || event === "rename" || location.recursive === true)) process.send?.({ directory: location.directory, eventType: event, filename: filename === null ? null : String(filename), id: message.id });',
5248
5612
  " });",
5249
5613
  ' watcher.on("error", () => process.send?.({ failed: true, id: message.id }));',
5250
5614
  " watchers.push(watcher);",
@@ -5266,16 +5630,18 @@ const WINDOWS_WATCH_BROKER_SOURCE = [
5266
5630
  ].join("\n");
5267
5631
 
5268
5632
  /**
5269
- * Report whether either live notification observed a membership event. This is
5270
- * positive evidence that the generation is stale, so it outranks the question
5271
- * of whether the notifications still work.
5633
+ * Report whether the project walk observed a membership event. This is positive
5634
+ * evidence that the program's root set changed, so it outranks the question of
5635
+ * whether the notifications still work.
5636
+ *
5637
+ * Host and resolution-candidate trackers cover the union of every module's
5638
+ * inputs. Their events remain path witnesses: the requested module's narrow
5639
+ * validation decides whether that path is relevant. Promoting one of them to a
5640
+ * project-wide verdict would discard a generation when an unreachable external
5641
+ * input changes, defeating per-file completeness and doing needless compiles.
5272
5642
  */
5273
5643
  function reportsMembershipChange(cached: TtscCachedProjectTransform): boolean {
5274
- return (
5275
- cached.projectMutationTracker?.membershipChanged === true ||
5276
- cached.hostInputMutationTracker?.membershipChanged === true ||
5277
- cached.candidateMutationTracker?.membershipChanged === true
5278
- );
5644
+ return cached.projectMutationTracker?.membershipChanged === true;
5279
5645
  }
5280
5646
 
5281
5647
  /**
@@ -5326,11 +5692,18 @@ function drainOnNextTurn(): Promise<void> {
5326
5692
  async function settleProjectMutationEvents(
5327
5693
  cached: TtscCachedProjectTransform,
5328
5694
  ): Promise<void> {
5329
- const trackers = [
5695
+ await settleMutationTrackers([
5330
5696
  cached.projectMutationTracker,
5331
5697
  cached.hostInputMutationTracker,
5332
5698
  cached.candidateMutationTracker,
5333
- ].filter(
5699
+ ]);
5700
+ }
5701
+
5702
+ /** Settle one set of optional trackers through one shared barrier each. */
5703
+ async function settleMutationTrackers(
5704
+ candidates: readonly (TtscProjectMutationTracker | undefined)[],
5705
+ ): Promise<void> {
5706
+ const trackers = candidates.filter(
5334
5707
  (tracker): tracker is TtscProjectMutationTracker => tracker !== undefined,
5335
5708
  );
5336
5709
  await Promise.all(
@@ -5712,15 +6085,26 @@ function matchesCachedExternalInputs(cached: TtscCachedProjectTransform): {
5712
6085
  const spelling = path.resolve(file);
5713
6086
  const observation = cached.externalInputObservations?.[spelling];
5714
6087
  if (observation !== undefined) {
6088
+ const before = inputMetadataEvidence(file, filesystem);
5715
6089
  if (
5716
- !matchesGraphInputObservation(
5717
- file,
5718
- observation,
5719
- filesystem,
5720
- state.identityContext,
5721
- )
6090
+ before !== undefined &&
6091
+ before.separable &&
6092
+ recordedSignatures[spelling] === before.signature
5722
6093
  ) {
6094
+ signatures[spelling] = before.signature;
6095
+ continue;
6096
+ }
6097
+ const observed = matchesGraphInputObservation(
6098
+ file,
6099
+ observation,
6100
+ filesystem,
6101
+ state.identityContext,
6102
+ );
6103
+ const after = inputMetadataSignature(file, filesystem);
6104
+ if (!observed) {
5723
6105
  matches = false;
6106
+ } else if (before?.separable === true && before.signature === after) {
6107
+ signatures[spelling] = after;
5724
6108
  }
5725
6109
  continue;
5726
6110
  }
@@ -6328,6 +6712,7 @@ function declaredProjectInputKeys(
6328
6712
  if (state.declaredInputKeysBuilt !== true) {
6329
6713
  state.declaredInputKeys = selectDeclaredProjectInputKeys({
6330
6714
  identities: state.identityContext,
6715
+ projectInputHashes: cached.inputHashes,
6331
6716
  projectRoot: cached.projectRoot,
6332
6717
  result: cached.result,
6333
6718
  scratchDirectory: cached.scratchDirectory,
@@ -6340,11 +6725,16 @@ function declaredProjectInputKeys(
6340
6725
  /**
6341
6726
  * Project-walk keys of every input the envelope declares: the reference graph's
6342
6727
  * edge endpoints, globals, config chain, and resolution candidates, plus the
6343
- * universal host inputs. Returns `undefined` for an envelope with no graph,
6344
- * which declares no input set and therefore keeps whole-walk comparison.
6728
+ * universal host inputs, intersected with the files the project walk actually
6729
+ * hashed. Out-of-walk declarations and candidates carry their own graph proof;
6730
+ * asking the project observer to witness them as well makes unrelated activity
6731
+ * in ignored directories invalidate an otherwise complete generation. Returns
6732
+ * `undefined` for an envelope with no graph, which declares no input set and
6733
+ * therefore keeps whole-walk comparison.
6345
6734
  */
6346
6735
  function selectDeclaredProjectInputKeys(props: {
6347
6736
  identities: FilesystemPathIdentityContext;
6737
+ projectInputHashes: Readonly<Record<string, string>>;
6348
6738
  projectRoot: string;
6349
6739
  result: ITtscCompilerTransformation;
6350
6740
  scratchDirectory?: string;
@@ -6358,7 +6748,10 @@ function selectDeclaredProjectInputKeys(props: {
6358
6748
  if (typeof entry !== "string" || entry.length === 0) return;
6359
6749
  const absolute = path.resolve(props.projectRoot, entry);
6360
6750
  if (isTransformScratchInput(absolute, props.scratchDirectory)) return;
6361
- keys.add(toProjectKey(props.projectRoot, absolute, props.identities));
6751
+ const key = toProjectKey(props.projectRoot, absolute, props.identities);
6752
+ if (Object.prototype.hasOwnProperty.call(props.projectInputHashes, key)) {
6753
+ keys.add(key);
6754
+ }
6362
6755
  };
6363
6756
  for (const [source, targets] of Object.entries(graph.edges ?? {})) {
6364
6757
  add(source);
@@ -6690,6 +7083,7 @@ async function transformProject(props: {
6690
7083
  deliveryEpoch?: number;
6691
7084
  filesystem: TtscTransformFilesystemOperations;
6692
7085
  plugins?: ResolvedTtscUnpluginOptions["plugins"];
7086
+ retainProjectMembership: boolean;
6693
7087
  trackProjectMembership: boolean;
6694
7088
  tsconfig: string;
6695
7089
  }): Promise<TtscCachedProjectTransform> {
@@ -6698,8 +7092,7 @@ async function transformProject(props: {
6698
7092
  const cached = await captureTransformGeneration(props);
6699
7093
  if (
6700
7094
  cached.configStateComplete !== false &&
6701
- (!props.trackProjectMembership ||
6702
- cached.result.type !== "success" ||
7095
+ (cached.result.type !== "success" ||
6703
7096
  cached.projectSnapshotComplete === true)
6704
7097
  ) {
6705
7098
  return cached;
@@ -6738,6 +7131,7 @@ async function captureTransformGeneration(props: {
6738
7131
  deliveryEpoch?: number;
6739
7132
  filesystem: TtscTransformFilesystemOperations;
6740
7133
  plugins?: ResolvedTtscUnpluginOptions["plugins"];
7134
+ retainProjectMembership: boolean;
6741
7135
  trackProjectMembership: boolean;
6742
7136
  tsconfig: string;
6743
7137
  }): Promise<TtscCachedProjectTransform> {
@@ -6756,7 +7150,7 @@ async function captureTransformGeneration(props: {
6756
7150
  let retainCandidateTracker = false;
6757
7151
  let captured: TtscCachedProjectTransform | undefined;
6758
7152
  try {
6759
- if (props.trackProjectMembership) {
7153
+ if (props.retainProjectMembership) {
6760
7154
  try {
6761
7155
  clockReferenceDirectory = createTransformScratchDirectory(
6762
7156
  projectRoot,
@@ -6807,6 +7201,11 @@ async function captureTransformGeneration(props: {
6807
7201
  tracker = props.trackProjectMembership
6808
7202
  ? await createProjectMutationTracker(
6809
7203
  before.projectDirectories,
7204
+ new Set(
7205
+ Object.keys(before.hashes)
7206
+ .filter((key) => !before.notificationUnsafeInputs.has(key))
7207
+ .map((key) => path.resolve(projectRoot, key)),
7208
+ ),
6810
7209
  props.filesystem,
6811
7210
  membershipPolicy,
6812
7211
  )
@@ -6846,15 +7245,26 @@ async function captureTransformGeneration(props: {
6846
7245
  scratchDirectory,
6847
7246
  temporaryTsconfig,
6848
7247
  });
7248
+ const externalInputPaths = selectExternalInputPaths({
7249
+ filesystem: props.filesystem,
7250
+ membershipPolicy,
7251
+ projectRoot,
7252
+ result,
7253
+ scratchDirectory,
7254
+ temporaryTsconfig,
7255
+ });
7256
+ const persistentValidationInputs = [
7257
+ ...new Set([...persistentHostInputs, ...externalInputPaths]),
7258
+ ];
6849
7259
  // The generation's absent resolution candidates, which get a watcher of
6850
7260
  // their own below; watching one is what lets a delivery stop probing it
6851
7261
  // (samchon/ttsc#1261). The validation manifest stays built from the
6852
7262
  // universal inputs alone, so nothing else about a candidate changes.
6853
7263
  //
6854
- // Derived only where a tracker could carry it: a build-scoped adapter opens
6855
- // no watcher, so probing every candidate's existence here would be work
6856
- // whose answer nothing can read.
6857
- const notifiableAbsence = props.trackProjectMembership
7264
+ // Derived only where a retained tracker could carry it: a build-scoped
7265
+ // adapter keeps only the compile-time project observer, so probing every
7266
+ // candidate here would be work whose answer nothing can later read.
7267
+ const notifiableAbsence = props.retainProjectMembership
6858
7268
  ? selectNotifiableAbsentInputs({
6859
7269
  filesystem: props.filesystem,
6860
7270
  projectRoot,
@@ -6863,14 +7273,18 @@ async function captureTransformGeneration(props: {
6863
7273
  temporaryTsconfig,
6864
7274
  })
6865
7275
  : { candidates: [], watched: [] };
6866
- hostInputTracker = props.trackProjectMembership
7276
+ hostInputTracker = props.retainProjectMembership
6867
7277
  ? await createHostInputMutationTracker(
6868
- persistentHostInputs,
7278
+ persistentValidationInputs,
6869
7279
  props.filesystem,
6870
7280
  // A universal input never reaches the per-input loop that consults a
6871
7281
  // coverage claim: an absent one is proven by its directory listing
6872
7282
  // instead, which re-resolves the spelling every delivery.
6873
- new Set(),
7283
+ new Set(
7284
+ persistentValidationInputs.map((input) => path.resolve(input)),
7285
+ ),
7286
+ "all",
7287
+ projectRoot,
6874
7288
  )
6875
7289
  : undefined;
6876
7290
  // The candidates and the directories carrying them get their own tracker,
@@ -6887,16 +7301,9 @@ async function captureTransformGeneration(props: {
6887
7301
  props.filesystem,
6888
7302
  new Set(notifiableAbsence.candidates),
6889
7303
  "rename",
7304
+ projectRoot,
6890
7305
  )
6891
7306
  : undefined;
6892
- const externalInputPaths = selectExternalInputPaths({
6893
- filesystem: props.filesystem,
6894
- membershipPolicy,
6895
- projectRoot,
6896
- result,
6897
- scratchDirectory,
6898
- temporaryTsconfig,
6899
- });
6900
7307
  const inputSnapshot = collectProjectInputSnapshot(
6901
7308
  projectRoot,
6902
7309
  identities,
@@ -6911,10 +7318,16 @@ async function captureTransformGeneration(props: {
6911
7318
  // provable from its own recorded state.
6912
7319
  const declaredInputs = selectDeclaredProjectInputKeys({
6913
7320
  identities,
7321
+ projectInputHashes: inputSnapshot.hashes,
6914
7322
  projectRoot,
6915
7323
  result,
6916
7324
  scratchDirectory,
6917
7325
  });
7326
+ // The before/after snapshots prove bytes and metadata. Drain the watcher
7327
+ // opened before compilation as the independent A-B-A witness: a producer
7328
+ // can restore both bytes and timestamps before the second walk, but it
7329
+ // cannot withdraw the already queued content event.
7330
+ await settleMutationTrackers([tracker, hostInputTracker, candidateTracker]);
6918
7331
  const walkStable =
6919
7332
  configStable &&
6920
7333
  walkSnapshotComplete(before, declaredInputs) &&
@@ -6929,6 +7342,11 @@ async function captureTransformGeneration(props: {
6929
7342
  before.projectDirectories,
6930
7343
  inputSnapshot.projectDirectories,
6931
7344
  ) &&
7345
+ !trackerChangedDeclaredProjectInput(
7346
+ tracker,
7347
+ declaredInputs,
7348
+ projectRoot,
7349
+ ) &&
6932
7350
  tracker?.membershipChanged !== true &&
6933
7351
  hostInputTracker?.membershipChanged !== true &&
6934
7352
  candidateTracker?.membershipChanged !== true;
@@ -7027,6 +7445,11 @@ async function captureTransformGeneration(props: {
7027
7445
  cached,
7028
7446
  props.currentFile,
7029
7447
  );
7448
+ restrictNotificationCoverageToProvenInputs(
7449
+ hostInputTracker,
7450
+ cached,
7451
+ universalInputCapture.validation,
7452
+ );
7030
7453
  mergeGenerationProofFailures(failures, universalInputCapture.failures);
7031
7454
  const graphProofs =
7032
7455
  graphFailures.entries.length === 0 && graphFailures.omitted === 0;
@@ -7059,7 +7482,10 @@ async function captureTransformGeneration(props: {
7059
7482
  // Attach notifications only while they can actually prove membership. A
7060
7483
  // generation that could not open its watchers keeps its recorded snapshot
7061
7484
  // and validates through it, rather than losing the cache entirely.
7062
- const notifying = stableProjectSnapshot && notificationsAvailable;
7485
+ const notifying =
7486
+ props.retainProjectMembership &&
7487
+ stableProjectSnapshot &&
7488
+ notificationsAvailable;
7063
7489
  if (notifying && tracker !== undefined) {
7064
7490
  cached.projectMutationTracker = tracker;
7065
7491
  }
@@ -7152,6 +7578,31 @@ async function captureTransformGeneration(props: {
7152
7578
  return captured;
7153
7579
  }
7154
7580
 
7581
+ /** Whether a compile-time content event overlaps any declared project input. */
7582
+ function trackerChangedDeclaredProjectInput(
7583
+ tracker: TtscProjectMutationTracker | undefined,
7584
+ declared: ReadonlySet<string> | undefined,
7585
+ projectRoot: string,
7586
+ ): boolean {
7587
+ if (tracker === undefined) return false;
7588
+ if (tracker.changesOmitted) return true;
7589
+ if (tracker.changes.size === 0) return false;
7590
+ if (declared === undefined) return true;
7591
+ const inputs = [...declared].map((input) => path.resolve(projectRoot, input));
7592
+ for (const changed of tracker.changes) {
7593
+ if (
7594
+ inputs.some(
7595
+ (input) =>
7596
+ tracker.overlaps?.(input, changed) ??
7597
+ (pathIsWithin(input, changed) || pathIsWithin(changed, input)),
7598
+ )
7599
+ ) {
7600
+ return true;
7601
+ }
7602
+ }
7603
+ return false;
7604
+ }
7605
+
7155
7606
  /** Exclude disposed transform scratch from live host-input tracking. */
7156
7607
  function selectPersistentHostInputs(props: {
7157
7608
  filesystem: TtscTransformFilesystemOperations;
@@ -7858,13 +8309,12 @@ function formatUnknownError(error: unknown): string {
7858
8309
  /**
7859
8310
  * Remove terminal colour and cursor sequences from text the adapter surfaces.
7860
8311
  *
7861
- * An ordinary type error reaches the adapter as an `"exception"` envelope whose
7862
- * `error` is the host's own rendered output, colour and all, and the envelope
7863
- * carries no structured diagnostics to format instead. What the adapter hands
7864
- * back is not going to a terminal: it becomes the `Error` a bundler reports, so
7865
- * it lands in a Vite overlay, a webpack error report or a CI annotation, where
7866
- * the escapes render as literal noise around the file and line the reader needs
7867
- * (samchon/ttsc#1312).
8312
+ * An opaque host exception can contain the host's own rendered output, colour
8313
+ * and all, with no structured diagnostics to format instead. What the adapter
8314
+ * hands back is not going to a terminal: it becomes the `Error` a bundler
8315
+ * reports, so it lands in a Vite overlay, a webpack error report or a CI
8316
+ * annotation, where the escapes render as literal noise around the file and
8317
+ * line the reader needs (samchon/ttsc#1312).
7868
8318
  *
7869
8319
  * The colour originates in the host's rendering rather than in anything this
7870
8320
  * adapter configures, so this is the adapter-side repair, applied to every