@ttsc/unplugin 0.30.3 → 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
 
@@ -315,6 +319,9 @@ const MAX_GENERATION_PROOF_FAILURES = 8;
315
319
  /** Maximum exact mutation paths kept after a tracker already proved a change. */
316
320
  const MAX_GENERATION_MUTATION_PATHS = 8;
317
321
 
322
+ /** Maximum unrelated roots watched before snapshot validation takes over. */
323
+ const MAX_HOST_INPUT_WATCH_SCOPES = 16;
324
+
318
325
  /** One retry absorbs a transient watch write without admitting an infinite loop. */
319
326
  const TRANSFORM_GENERATION_ATTEMPTS = 2;
320
327
 
@@ -562,6 +569,7 @@ export interface TtscTransformFilesystemOperations {
562
569
  directory: string,
563
570
  listener: (eventType: string, filename: string | null) => void,
564
571
  onError: () => void,
572
+ recursive?: boolean,
565
573
  ): { close: () => void };
566
574
  }
567
575
 
@@ -986,6 +994,11 @@ export async function transformTtsc(
986
994
  deliveryEpoch: epoch,
987
995
  filesystem,
988
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,
989
1002
  trackProjectMembership: cache !== undefined,
990
1003
  tsconfig,
991
1004
  });
@@ -1318,10 +1331,14 @@ interface TtscHostInputValidation {
1318
1331
  {
1319
1332
  path: string;
1320
1333
  /**
1321
- * Whether the recorded state of this input came from reading its bytes.
1322
- * An input that existed but could not be read records a missing state, so
1323
- * no signature may stand in for it: its metadata holds still while the
1324
- * 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.
1325
1342
  */
1326
1343
  readable: boolean;
1327
1344
  realpath: string | null;
@@ -2750,6 +2767,28 @@ function matchesNarrowPersistentInputs(
2750
2767
  return true;
2751
2768
  }
2752
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
+
2753
2792
  /**
2754
2793
  * Validate one derived input against the generation, skipping the content read
2755
2794
  * while the recorded metadata signature still holds and its freshly minted
@@ -2777,6 +2816,12 @@ function matchesProvenInput(
2777
2816
  state: TtscEnvelopeDerivation,
2778
2817
  input: string,
2779
2818
  ): boolean {
2819
+ if (
2820
+ trackerProvesInputUnchanged(cached.projectMutationTracker, input) ||
2821
+ trackerProvesInputUnchanged(cached.hostInputMutationTracker, input)
2822
+ ) {
2823
+ return true;
2824
+ }
2780
2825
  const observation = cached.externalInputObservations?.[path.resolve(input)];
2781
2826
  if (observation !== undefined) {
2782
2827
  if (
@@ -2786,7 +2831,25 @@ function matchesProvenInput(
2786
2831
  ) {
2787
2832
  return true;
2788
2833
  }
2789
- 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;
2790
2853
  }
2791
2854
  const slot = inputSignatureSlot(cached, state, input);
2792
2855
  if (slot === undefined) {
@@ -2840,12 +2903,7 @@ function notifiesAbsence(
2840
2903
  cached: TtscCachedProjectTransform,
2841
2904
  input: string,
2842
2905
  ): boolean {
2843
- const tracker = cached.candidateMutationTracker;
2844
- return (
2845
- tracker !== undefined &&
2846
- !tracker.failed &&
2847
- tracker.covered?.has(path.resolve(input)) === true
2848
- );
2906
+ return trackerProvesInputUnchanged(cached.candidateMutationTracker, input);
2849
2907
  }
2850
2908
 
2851
2909
  /**
@@ -2906,6 +2964,14 @@ function matchesUniversalHostInputs(
2906
2964
  cached: TtscCachedProjectTransform,
2907
2965
  validation: TtscHostInputValidation,
2908
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;
2909
2975
  return (
2910
2976
  matchesUniversalHostInputEntries(cached, validation) &&
2911
2977
  matchesUniversalHostInputProbes(cached, validation)
@@ -2940,6 +3006,15 @@ function matchesUniversalHostInputEntries(
2940
3006
  if (!matchesRecordedInput(cached, entry.path)) {
2941
3007
  return false;
2942
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
+ }
2943
3018
  if (evidence === undefined) return false;
2944
3019
  // Re-earn the proof under the rules the capture applies: an entry whose
2945
3020
  // recorded state came from reading nothing keeps its content comparison, a
@@ -3178,6 +3253,43 @@ function captureUniversalHostInputValidation(
3178
3253
  return { failures, validation };
3179
3254
  }
3180
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
+
3181
3293
  /**
3182
3294
  * The recorded state of an input the generation read nothing from: absent, or
3183
3295
  * present but unreadable. It is deliberately not a hash, so no signature may
@@ -3314,6 +3426,8 @@ function disposeFilesystemClockReference(referenceDirectory: string): void {
3314
3426
  interface TtscInputMetadataEvidence {
3315
3427
  /** The joined metadata signature of the lexical path and its link target. */
3316
3428
  signature: string;
3429
+ /** Whether a directory observer can account for every content mutation. */
3430
+ notificationAuthoritative: boolean;
3317
3431
  /**
3318
3432
  * Whether a later write is guaranteed to move this signature. Only a
3319
3433
  * signature captured with this evidence may be recorded to stand in for a
@@ -3359,6 +3473,7 @@ function inputMetadataEvidence(
3359
3473
  link.ctimeNs,
3360
3474
  "missing-target",
3361
3475
  ].join(":"),
3476
+ notificationAuthoritative: false,
3362
3477
  separable: false,
3363
3478
  };
3364
3479
  }
@@ -3367,17 +3482,26 @@ function inputMetadataEvidence(
3367
3482
  signature: [
3368
3483
  link.dev,
3369
3484
  link.ino,
3485
+ link.nlink,
3370
3486
  link.mode,
3371
3487
  link.size,
3372
3488
  link.mtimeNs,
3373
3489
  link.ctimeNs,
3374
3490
  target.dev,
3375
3491
  target.ino,
3492
+ target.nlink,
3376
3493
  target.mode,
3377
3494
  target.size,
3378
3495
  target.mtimeNs,
3379
3496
  target.ctimeNs,
3380
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)),
3381
3505
  // Both halves must be separable: a write remints the target's stamp, a
3382
3506
  // link retarget the link's own, and either one hiding inside its recorded
3383
3507
  // tick would evade the skipped content and realpath comparisons.
@@ -3903,12 +4027,14 @@ function captureExternalInputSnapshot(
3903
4027
  });
3904
4028
  continue;
3905
4029
  }
4030
+ const before = inputMetadataEvidence(input, filesystem);
3906
4031
  const mismatches = graphInputObservationFailures(
3907
4032
  input,
3908
4033
  predicateObservation,
3909
4034
  filesystem,
3910
4035
  state.identityContext,
3911
4036
  );
4037
+ const after = inputMetadataSignature(input, filesystem);
3912
4038
  if (mismatches.length !== 0) complete = false;
3913
4039
  for (const kind of mismatches) {
3914
4040
  recordGenerationProofFailure(failures, {
@@ -3917,6 +4043,7 @@ function captureExternalInputSnapshot(
3917
4043
  path: input,
3918
4044
  });
3919
4045
  }
4046
+ if (mismatches.length === 0) record(input, before, after);
3920
4047
  observations[spelling] = predicateObservation;
3921
4048
  continue;
3922
4049
  }
@@ -4292,12 +4419,14 @@ function collectProjectInputSnapshot(
4292
4419
  directoryComplete: boolean;
4293
4420
  fileSignatures: Record<string, string>;
4294
4421
  hashes: Record<string, string>;
4422
+ notificationUnsafeInputs: Set<string>;
4295
4423
  projectDirectories: TtscProjectDirectorySnapshot[];
4296
4424
  provenSignatures: Record<string, string>;
4297
4425
  unstableFiles: Set<string>;
4298
4426
  walkFailures: TtscProjectWalkFailure[];
4299
4427
  } {
4300
4428
  const hashes: Record<string, string> = {};
4429
+ const notificationUnsafeInputs = new Set<string>();
4301
4430
  const fileSignatures: Record<string, string> = {};
4302
4431
  const provenSignatures: Record<string, string> = {};
4303
4432
  const unstableFiles = new Set<string>();
@@ -4322,6 +4451,9 @@ function collectProjectInputSnapshot(
4322
4451
  continue;
4323
4452
  }
4324
4453
  const before = inputMetadataEvidence(file, filesystem);
4454
+ if (before?.notificationAuthoritative !== true) {
4455
+ notificationUnsafeInputs.add(key);
4456
+ }
4325
4457
  // A file whose signature still equals the one captured around the read
4326
4458
  // that produced the recorded hash carries that content, so the whole
4327
4459
  // project does not have to be re-read to prove one delivery. Recheck the
@@ -4340,24 +4472,27 @@ function collectProjectInputSnapshot(
4340
4472
  continue;
4341
4473
  }
4342
4474
  const contents = filesystem.readFile(file);
4343
- const after = inputMetadataSignature(file, filesystem);
4475
+ const after = inputMetadataEvidence(file, filesystem);
4476
+ if (after?.notificationAuthoritative !== true) {
4477
+ notificationUnsafeInputs.add(key);
4478
+ }
4344
4479
  hashes[key] = hashText(contents);
4345
4480
  if (
4346
4481
  before === undefined ||
4347
4482
  after === undefined ||
4348
- before.signature !== after
4483
+ before.signature !== after.signature
4349
4484
  ) {
4350
4485
  complete = false;
4351
4486
  unstableFiles.add(key);
4352
4487
  walkFailures.push({ kind: "file-changed-during-read", path: file });
4353
4488
  } else {
4354
- fileSignatures[key] = after;
4489
+ fileSignatures[key] = after.signature;
4355
4490
  // Only a signature whose stamp's tick the filesystem's clock provably
4356
4491
  // left before this read may later stand in for the content comparison
4357
4492
  // ({@link stampSeparable}); the raw signature above still participates
4358
4493
  // in the generation-time stability comparison.
4359
4494
  if (before.separable) {
4360
- provenSignatures[key] = after;
4495
+ provenSignatures[key] = after.signature;
4361
4496
  }
4362
4497
  }
4363
4498
  } catch {
@@ -4379,6 +4514,7 @@ function collectProjectInputSnapshot(
4379
4514
  directoryComplete: walked.complete && attributed,
4380
4515
  fileSignatures,
4381
4516
  hashes,
4517
+ notificationUnsafeInputs,
4382
4518
  projectDirectories: walked.directories,
4383
4519
  provenSignatures,
4384
4520
  unstableFiles,
@@ -4619,13 +4755,14 @@ function openDirectoryWatch(
4619
4755
  directory: string,
4620
4756
  listener: (eventType: string, filename: string | null) => void,
4621
4757
  onError: () => void,
4758
+ recursive = false,
4622
4759
  ): { close: () => void } {
4623
4760
  if (filesystem.watch !== undefined) {
4624
- return filesystem.watch(directory, listener, onError);
4761
+ return filesystem.watch(directory, listener, onError, recursive);
4625
4762
  }
4626
4763
  const watcher = fs.watch(
4627
4764
  directory,
4628
- { persistent: false },
4765
+ { persistent: false, recursive },
4629
4766
  (eventType, filename) =>
4630
4767
  listener(eventType, filename === null ? null : String(filename)),
4631
4768
  );
@@ -4654,140 +4791,289 @@ function closeDirectoryWatches(watchers: { close: () => void }[]): void {
4654
4791
  /** Watch every walked directory for membership changes after generation. */
4655
4792
  async function createProjectMutationTracker(
4656
4793
  directories: readonly TtscProjectDirectorySnapshot[],
4794
+ covered: ReadonlySet<string>,
4657
4795
  filesystem: TtscTransformFilesystemOperations = DEFAULT_FILESYSTEM_OPERATIONS,
4658
4796
  policy: ITtscProjectMembershipPolicy = PERMISSIVE_PROJECT_MEMBERSHIP_POLICY,
4659
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;
4660
4812
  const tracker: TtscProjectMutationTracker = {
4661
4813
  changes: new Set(),
4662
4814
  changesOmitted: false,
4663
- close: () => undefined,
4815
+ close: () => {
4816
+ tracker.failed = true;
4817
+ },
4818
+ covered: authoritative,
4664
4819
  failed: false,
4665
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
+ );
4666
4852
  };
4667
4853
  if (process.platform === "win32" && filesystem.watch === undefined) {
4668
4854
  await registerWindowsProjectMutationTracker(
4669
4855
  tracker,
4670
- directories.map((directory) => ({ directory: directory.path })),
4856
+ [{ directory: root, recursive: true }],
4671
4857
  false,
4672
4858
  filesystem,
4673
- (location, filename) =>
4674
- reportsProgramMembership(
4675
- path.join(location, filename),
4676
- filename,
4677
- policy,
4678
- filesystem,
4679
- ),
4859
+ reportsMembership,
4860
+ (_location, filename) =>
4861
+ isPossibleProgramFileName(path.basename(filename), policy),
4862
+ reportsNewMembership,
4680
4863
  );
4681
4864
  return tracker;
4682
4865
  }
4683
4866
  const watchers: { close: () => void }[] = [];
4684
- tracker.close = () => closeDirectoryWatches(watchers);
4685
- for (const directory of directories) {
4686
- try {
4687
- watchers.push(
4688
- openDirectoryWatch(
4689
- filesystem,
4690
- directory.path,
4691
- (eventType, filename) => {
4692
- if (eventType !== "rename") {
4693
- return;
4694
- }
4695
- if (
4696
- filename !== null &&
4697
- !reportsProgramMembership(
4698
- path.join(directory.path, filename),
4699
- filename,
4700
- policy,
4701
- filesystem,
4702
- )
4703
- ) {
4704
- return;
4705
- }
4706
- recordProjectMutation(
4707
- tracker,
4708
- filename === null
4709
- ? directory.path
4710
- : path.join(directory.path, filename),
4711
- );
4712
- },
4713
- () => {
4714
- tracker.failed = true;
4715
- },
4716
- ),
4717
- );
4718
- } catch {
4719
- tracker.failed = true;
4720
- }
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;
4721
4901
  }
4722
4902
  return tracker;
4723
4903
  }
4724
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
+
4725
4922
  /** Watch exact universal inputs, or their nearest existing parent if missing. */
4726
4923
  async function createHostInputMutationTracker(
4727
4924
  inputs: readonly string[],
4728
4925
  filesystem: TtscTransformFilesystemOperations,
4729
4926
  covered: ReadonlySet<string>,
4730
4927
  events: "all" | "rename" = "all",
4928
+ preferredRoot?: string,
4731
4929
  ): Promise<TtscProjectMutationTracker> {
4732
4930
  const identities = createHostPathIdentityContext(filesystem);
4733
- 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<
4734
4962
  string,
4735
- { directory: string; names: Set<string> }
4963
+ {
4964
+ directory: string;
4965
+ names: Set<string>;
4966
+ paths?: Set<string>;
4967
+ recursive?: boolean;
4968
+ }
4736
4969
  >();
4970
+ const internalRoot =
4971
+ preferredRoot === undefined ? undefined : path.resolve(preferredRoot);
4737
4972
  for (const input of inputs) {
4738
4973
  const absolute = path.resolve(input);
4739
4974
  const probe = filesystem.exists(absolute)
4740
4975
  ? { directory: path.dirname(absolute), name: path.basename(absolute) }
4741
4976
  : missingPathProbe(absolute, filesystem);
4742
- const directoryIdentity = identities.resolve(probe.directory);
4743
- 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);
4744
4982
  if (location === undefined) {
4745
4983
  location = {
4746
4984
  directory: directoryIdentity.path,
4747
4985
  names: new Set<string>(),
4986
+ ...(internal ? { paths: new Set<string>(), recursive: true } : {}),
4748
4987
  };
4749
- 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
+ );
4750
5001
  }
4751
- location.names.add(
4752
- normalizeHostInputName(
4753
- probe.name,
4754
- identities.caseSensitive(directoryIdentity.path),
4755
- ),
4756
- );
4757
5002
  }
4758
- const locations = [...namesByDirectory.values()].map((location) => ({
4759
- directory: location.directory,
4760
- names: [...location.names],
4761
- }));
5003
+ const locations = [...locationsByDirectory.values()];
4762
5004
  const tracker: TtscProjectMutationTracker = {
4763
5005
  changes: new Set(),
4764
5006
  changesOmitted: false,
4765
- close: () => undefined,
5007
+ close: () => {
5008
+ tracker.failed = true;
5009
+ },
4766
5010
  // Coverage is the caller's claim, and it is required rather than derived
4767
5011
  // from the input list: an input is watched by its exact name here, but only
4768
5012
  // the caller knows whether the path leading to it is watched as well, which
4769
5013
  // is what a later validation needs before it trusts the watcher instead of
4770
5014
  // probing the path again. Deriving it here would hand that claim to every
4771
5015
  // future caller by default (samchon/ttsc#1261).
4772
- covered,
5016
+ covered: authoritative,
5017
+ contentAuthoritative: filesystem.watch === undefined,
4773
5018
  failed: false,
4774
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
+ );
4775
5053
  };
4776
5054
  if (process.platform === "win32" && filesystem.watch === undefined) {
4777
5055
  await registerWindowsProjectMutationTracker(
4778
5056
  tracker,
4779
- locations,
5057
+ locations.map((location) => ({
5058
+ directory: location.directory,
5059
+ ...(location.recursive === true
5060
+ ? { recursive: true }
5061
+ : { names: [...location.names] }),
5062
+ })),
4780
5063
  events === "all",
4781
5064
  filesystem,
5065
+ matches,
5066
+ matches,
4782
5067
  );
4783
5068
  return tracker;
4784
5069
  }
4785
5070
  const watchers: { close: () => void }[] = [];
4786
- tracker.close = () => closeDirectoryWatches(watchers);
5071
+ tracker.close = () => {
5072
+ tracker.failed = true;
5073
+ closeDirectoryWatches(watchers);
5074
+ };
4787
5075
  for (const location of locations) {
4788
5076
  try {
4789
- const names = new Set(location.names);
4790
- const caseSensitive = identities.caseSensitive(location.directory);
4791
5077
  watchers.push(
4792
5078
  openDirectoryWatch(
4793
5079
  filesystem,
@@ -4796,22 +5082,22 @@ async function createHostInputMutationTracker(
4796
5082
  if (events === "rename" && eventType !== "rename") {
4797
5083
  return;
4798
5084
  }
4799
- const reported =
4800
- filename === null
4801
- ? null
4802
- : normalizeHostInputName(filename, caseSensitive);
4803
- if (reported === null || names.has(reported)) {
4804
- recordProjectMutation(
4805
- tracker,
5085
+ if (filename === null || matches(location.directory, filename)) {
5086
+ const changed =
4806
5087
  filename === null
4807
5088
  ? location.directory
4808
- : path.join(location.directory, filename),
4809
- );
5089
+ : path.join(location.directory, filename);
5090
+ if (eventType === "rename") {
5091
+ recordProjectMutation(tracker, changed);
5092
+ } else {
5093
+ recordProjectChange(tracker, changed);
5094
+ }
4810
5095
  }
4811
5096
  },
4812
5097
  () => {
4813
5098
  tracker.failed = true;
4814
5099
  },
5100
+ location.recursive === true,
4815
5101
  ),
4816
5102
  );
4817
5103
  } catch {
@@ -4821,6 +5107,47 @@ async function createHostInputMutationTracker(
4821
5107
  return tracker;
4822
5108
  }
4823
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
+
4824
5151
  /**
4825
5152
  * Whether a path lies inside a directory the configuration excludes.
4826
5153
  *
@@ -4915,6 +5242,14 @@ function recordProjectMutation(
4915
5242
  changed: string,
4916
5243
  ): void {
4917
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 {
4918
5253
  if (tracker.changes.has(changed)) return;
4919
5254
  if (tracker.changes.size < MAX_GENERATION_MUTATION_PATHS) {
4920
5255
  tracker.changes.add(changed);
@@ -4942,6 +5277,10 @@ interface WindowsProjectMutationBroker {
4942
5277
  * have already narrowed theirs by construction.
4943
5278
  */
4944
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;
4945
5284
  ready: () => void;
4946
5285
  /**
4947
5286
  * The walk's own spelling for each canonical directory the child watches,
@@ -4963,6 +5302,7 @@ let windowsProjectMutationBroker: WindowsProjectMutationBroker | undefined;
4963
5302
  interface WindowsMutationLocation {
4964
5303
  directory: string;
4965
5304
  names?: string[];
5305
+ recursive?: boolean;
4966
5306
  }
4967
5307
 
4968
5308
  /**
@@ -4983,6 +5323,8 @@ async function registerWindowsProjectMutationTracker(
4983
5323
  * name-watching trackers pass none, since they already watch exact names.
4984
5324
  */
4985
5325
  membership?: (location: string, filename: string) => boolean,
5326
+ content?: (location: string, filename: string) => boolean,
5327
+ changeAddsMembership?: (location: string, filename: string) => boolean,
4986
5328
  ): Promise<void> {
4987
5329
  const broker = getWindowsProjectMutationBroker();
4988
5330
  // The child watches canonical directories, and reports its events under that
@@ -5003,6 +5345,7 @@ async function registerWindowsProjectMutationTracker(
5003
5345
  return {
5004
5346
  directory,
5005
5347
  ...(location.names === undefined ? {} : { names: location.names }),
5348
+ ...(location.recursive === true ? { recursive: true } : {}),
5006
5349
  };
5007
5350
  });
5008
5351
  broker.pendingRegistrations += 1;
@@ -5016,13 +5359,16 @@ async function registerWindowsProjectMutationTracker(
5016
5359
  resolveReady = resolve;
5017
5360
  });
5018
5361
  broker.trackers.set(id, {
5019
- membership,
5362
+ ...(changeAddsMembership === undefined ? {} : { changeAddsMembership }),
5363
+ ...(content === undefined ? {} : { content }),
5364
+ ...(membership === undefined ? {} : { membership }),
5020
5365
  ready: resolveReady,
5021
5366
  spellings,
5022
5367
  tracker,
5023
5368
  });
5024
5369
  tracker.drain = () => drainWindowsProjectMutationBroker(broker);
5025
5370
  tracker.close = () => {
5371
+ tracker.failed = true;
5026
5372
  const active = broker.trackers.get(id);
5027
5373
  if (active === undefined) return;
5028
5374
  broker.trackers.delete(id);
@@ -5119,6 +5465,7 @@ function getWindowsProjectMutationBroker(): WindowsProjectMutationBroker {
5119
5465
  drained?: boolean;
5120
5466
  failed?: boolean;
5121
5467
  filename?: string | null;
5468
+ eventType?: string;
5122
5469
  id?: number;
5123
5470
  ready?: boolean;
5124
5471
  };
@@ -5141,19 +5488,33 @@ function getWindowsProjectMutationBroker(): WindowsProjectMutationBroker {
5141
5488
  // comparison and every recorded witness downstream expects.
5142
5489
  const reported =
5143
5490
  registration.spellings.get(record.directory) ?? record.directory;
5491
+ const changed =
5492
+ typeof record.filename === "string"
5493
+ ? path.join(reported, record.filename)
5494
+ : reported;
5144
5495
  if (
5145
5496
  typeof record.filename === "string" &&
5146
- registration.membership !== undefined &&
5147
- !registration.membership(reported, record.filename)
5497
+ registration.membership !== undefined
5148
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
+ }
5149
5515
  return;
5150
5516
  }
5151
- recordProjectMutation(
5152
- registration.tracker,
5153
- typeof record.filename === "string"
5154
- ? path.join(reported, record.filename)
5155
- : reported,
5156
- );
5517
+ recordProjectMutation(registration.tracker, changed);
5157
5518
  } else {
5158
5519
  registration.tracker.membershipChanged = true;
5159
5520
  }
@@ -5245,9 +5606,9 @@ const WINDOWS_WATCH_BROKER_SOURCE = [
5245
5606
  " for (const location of message.locations) {",
5246
5607
  " try {",
5247
5608
  " const names = location.names === undefined ? undefined : new Set(location.names.map((name) => name.toLowerCase()));",
5248
- " 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) => {",
5249
5610
  " const matches = names === undefined || filename === null || names.has(String(filename).toLowerCase());",
5250
- ' 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 });',
5251
5612
  " });",
5252
5613
  ' watcher.on("error", () => process.send?.({ failed: true, id: message.id }));',
5253
5614
  " watchers.push(watcher);",
@@ -5269,16 +5630,18 @@ const WINDOWS_WATCH_BROKER_SOURCE = [
5269
5630
  ].join("\n");
5270
5631
 
5271
5632
  /**
5272
- * Report whether either live notification observed a membership event. This is
5273
- * positive evidence that the generation is stale, so it outranks the question
5274
- * 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.
5275
5642
  */
5276
5643
  function reportsMembershipChange(cached: TtscCachedProjectTransform): boolean {
5277
- return (
5278
- cached.projectMutationTracker?.membershipChanged === true ||
5279
- cached.hostInputMutationTracker?.membershipChanged === true ||
5280
- cached.candidateMutationTracker?.membershipChanged === true
5281
- );
5644
+ return cached.projectMutationTracker?.membershipChanged === true;
5282
5645
  }
5283
5646
 
5284
5647
  /**
@@ -5329,11 +5692,18 @@ function drainOnNextTurn(): Promise<void> {
5329
5692
  async function settleProjectMutationEvents(
5330
5693
  cached: TtscCachedProjectTransform,
5331
5694
  ): Promise<void> {
5332
- const trackers = [
5695
+ await settleMutationTrackers([
5333
5696
  cached.projectMutationTracker,
5334
5697
  cached.hostInputMutationTracker,
5335
5698
  cached.candidateMutationTracker,
5336
- ].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(
5337
5707
  (tracker): tracker is TtscProjectMutationTracker => tracker !== undefined,
5338
5708
  );
5339
5709
  await Promise.all(
@@ -5715,15 +6085,26 @@ function matchesCachedExternalInputs(cached: TtscCachedProjectTransform): {
5715
6085
  const spelling = path.resolve(file);
5716
6086
  const observation = cached.externalInputObservations?.[spelling];
5717
6087
  if (observation !== undefined) {
6088
+ const before = inputMetadataEvidence(file, filesystem);
5718
6089
  if (
5719
- !matchesGraphInputObservation(
5720
- file,
5721
- observation,
5722
- filesystem,
5723
- state.identityContext,
5724
- )
6090
+ before !== undefined &&
6091
+ before.separable &&
6092
+ recordedSignatures[spelling] === before.signature
5725
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) {
5726
6105
  matches = false;
6106
+ } else if (before?.separable === true && before.signature === after) {
6107
+ signatures[spelling] = after;
5727
6108
  }
5728
6109
  continue;
5729
6110
  }
@@ -6331,6 +6712,7 @@ function declaredProjectInputKeys(
6331
6712
  if (state.declaredInputKeysBuilt !== true) {
6332
6713
  state.declaredInputKeys = selectDeclaredProjectInputKeys({
6333
6714
  identities: state.identityContext,
6715
+ projectInputHashes: cached.inputHashes,
6334
6716
  projectRoot: cached.projectRoot,
6335
6717
  result: cached.result,
6336
6718
  scratchDirectory: cached.scratchDirectory,
@@ -6343,11 +6725,16 @@ function declaredProjectInputKeys(
6343
6725
  /**
6344
6726
  * Project-walk keys of every input the envelope declares: the reference graph's
6345
6727
  * edge endpoints, globals, config chain, and resolution candidates, plus the
6346
- * universal host inputs. Returns `undefined` for an envelope with no graph,
6347
- * 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.
6348
6734
  */
6349
6735
  function selectDeclaredProjectInputKeys(props: {
6350
6736
  identities: FilesystemPathIdentityContext;
6737
+ projectInputHashes: Readonly<Record<string, string>>;
6351
6738
  projectRoot: string;
6352
6739
  result: ITtscCompilerTransformation;
6353
6740
  scratchDirectory?: string;
@@ -6361,7 +6748,10 @@ function selectDeclaredProjectInputKeys(props: {
6361
6748
  if (typeof entry !== "string" || entry.length === 0) return;
6362
6749
  const absolute = path.resolve(props.projectRoot, entry);
6363
6750
  if (isTransformScratchInput(absolute, props.scratchDirectory)) return;
6364
- 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
+ }
6365
6755
  };
6366
6756
  for (const [source, targets] of Object.entries(graph.edges ?? {})) {
6367
6757
  add(source);
@@ -6693,6 +7083,7 @@ async function transformProject(props: {
6693
7083
  deliveryEpoch?: number;
6694
7084
  filesystem: TtscTransformFilesystemOperations;
6695
7085
  plugins?: ResolvedTtscUnpluginOptions["plugins"];
7086
+ retainProjectMembership: boolean;
6696
7087
  trackProjectMembership: boolean;
6697
7088
  tsconfig: string;
6698
7089
  }): Promise<TtscCachedProjectTransform> {
@@ -6701,8 +7092,7 @@ async function transformProject(props: {
6701
7092
  const cached = await captureTransformGeneration(props);
6702
7093
  if (
6703
7094
  cached.configStateComplete !== false &&
6704
- (!props.trackProjectMembership ||
6705
- cached.result.type !== "success" ||
7095
+ (cached.result.type !== "success" ||
6706
7096
  cached.projectSnapshotComplete === true)
6707
7097
  ) {
6708
7098
  return cached;
@@ -6741,6 +7131,7 @@ async function captureTransformGeneration(props: {
6741
7131
  deliveryEpoch?: number;
6742
7132
  filesystem: TtscTransformFilesystemOperations;
6743
7133
  plugins?: ResolvedTtscUnpluginOptions["plugins"];
7134
+ retainProjectMembership: boolean;
6744
7135
  trackProjectMembership: boolean;
6745
7136
  tsconfig: string;
6746
7137
  }): Promise<TtscCachedProjectTransform> {
@@ -6759,7 +7150,7 @@ async function captureTransformGeneration(props: {
6759
7150
  let retainCandidateTracker = false;
6760
7151
  let captured: TtscCachedProjectTransform | undefined;
6761
7152
  try {
6762
- if (props.trackProjectMembership) {
7153
+ if (props.retainProjectMembership) {
6763
7154
  try {
6764
7155
  clockReferenceDirectory = createTransformScratchDirectory(
6765
7156
  projectRoot,
@@ -6810,6 +7201,11 @@ async function captureTransformGeneration(props: {
6810
7201
  tracker = props.trackProjectMembership
6811
7202
  ? await createProjectMutationTracker(
6812
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
+ ),
6813
7209
  props.filesystem,
6814
7210
  membershipPolicy,
6815
7211
  )
@@ -6849,15 +7245,26 @@ async function captureTransformGeneration(props: {
6849
7245
  scratchDirectory,
6850
7246
  temporaryTsconfig,
6851
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
+ ];
6852
7259
  // The generation's absent resolution candidates, which get a watcher of
6853
7260
  // their own below; watching one is what lets a delivery stop probing it
6854
7261
  // (samchon/ttsc#1261). The validation manifest stays built from the
6855
7262
  // universal inputs alone, so nothing else about a candidate changes.
6856
7263
  //
6857
- // Derived only where a tracker could carry it: a build-scoped adapter opens
6858
- // no watcher, so probing every candidate's existence here would be work
6859
- // whose answer nothing can read.
6860
- 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
6861
7268
  ? selectNotifiableAbsentInputs({
6862
7269
  filesystem: props.filesystem,
6863
7270
  projectRoot,
@@ -6866,14 +7273,18 @@ async function captureTransformGeneration(props: {
6866
7273
  temporaryTsconfig,
6867
7274
  })
6868
7275
  : { candidates: [], watched: [] };
6869
- hostInputTracker = props.trackProjectMembership
7276
+ hostInputTracker = props.retainProjectMembership
6870
7277
  ? await createHostInputMutationTracker(
6871
- persistentHostInputs,
7278
+ persistentValidationInputs,
6872
7279
  props.filesystem,
6873
7280
  // A universal input never reaches the per-input loop that consults a
6874
7281
  // coverage claim: an absent one is proven by its directory listing
6875
7282
  // instead, which re-resolves the spelling every delivery.
6876
- new Set(),
7283
+ new Set(
7284
+ persistentValidationInputs.map((input) => path.resolve(input)),
7285
+ ),
7286
+ "all",
7287
+ projectRoot,
6877
7288
  )
6878
7289
  : undefined;
6879
7290
  // The candidates and the directories carrying them get their own tracker,
@@ -6890,16 +7301,9 @@ async function captureTransformGeneration(props: {
6890
7301
  props.filesystem,
6891
7302
  new Set(notifiableAbsence.candidates),
6892
7303
  "rename",
7304
+ projectRoot,
6893
7305
  )
6894
7306
  : undefined;
6895
- const externalInputPaths = selectExternalInputPaths({
6896
- filesystem: props.filesystem,
6897
- membershipPolicy,
6898
- projectRoot,
6899
- result,
6900
- scratchDirectory,
6901
- temporaryTsconfig,
6902
- });
6903
7307
  const inputSnapshot = collectProjectInputSnapshot(
6904
7308
  projectRoot,
6905
7309
  identities,
@@ -6914,10 +7318,16 @@ async function captureTransformGeneration(props: {
6914
7318
  // provable from its own recorded state.
6915
7319
  const declaredInputs = selectDeclaredProjectInputKeys({
6916
7320
  identities,
7321
+ projectInputHashes: inputSnapshot.hashes,
6917
7322
  projectRoot,
6918
7323
  result,
6919
7324
  scratchDirectory,
6920
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]);
6921
7331
  const walkStable =
6922
7332
  configStable &&
6923
7333
  walkSnapshotComplete(before, declaredInputs) &&
@@ -6932,6 +7342,11 @@ async function captureTransformGeneration(props: {
6932
7342
  before.projectDirectories,
6933
7343
  inputSnapshot.projectDirectories,
6934
7344
  ) &&
7345
+ !trackerChangedDeclaredProjectInput(
7346
+ tracker,
7347
+ declaredInputs,
7348
+ projectRoot,
7349
+ ) &&
6935
7350
  tracker?.membershipChanged !== true &&
6936
7351
  hostInputTracker?.membershipChanged !== true &&
6937
7352
  candidateTracker?.membershipChanged !== true;
@@ -7030,6 +7445,11 @@ async function captureTransformGeneration(props: {
7030
7445
  cached,
7031
7446
  props.currentFile,
7032
7447
  );
7448
+ restrictNotificationCoverageToProvenInputs(
7449
+ hostInputTracker,
7450
+ cached,
7451
+ universalInputCapture.validation,
7452
+ );
7033
7453
  mergeGenerationProofFailures(failures, universalInputCapture.failures);
7034
7454
  const graphProofs =
7035
7455
  graphFailures.entries.length === 0 && graphFailures.omitted === 0;
@@ -7062,7 +7482,10 @@ async function captureTransformGeneration(props: {
7062
7482
  // Attach notifications only while they can actually prove membership. A
7063
7483
  // generation that could not open its watchers keeps its recorded snapshot
7064
7484
  // and validates through it, rather than losing the cache entirely.
7065
- const notifying = stableProjectSnapshot && notificationsAvailable;
7485
+ const notifying =
7486
+ props.retainProjectMembership &&
7487
+ stableProjectSnapshot &&
7488
+ notificationsAvailable;
7066
7489
  if (notifying && tracker !== undefined) {
7067
7490
  cached.projectMutationTracker = tracker;
7068
7491
  }
@@ -7155,6 +7578,31 @@ async function captureTransformGeneration(props: {
7155
7578
  return captured;
7156
7579
  }
7157
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
+
7158
7606
  /** Exclude disposed transform scratch from live host-input tracking. */
7159
7607
  function selectPersistentHostInputs(props: {
7160
7608
  filesystem: TtscTransformFilesystemOperations;