ttsc 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/cmd/ttscgraph/serve.go +264 -48
  2. package/cmd/ttscgraph/serve_early_error_encode_failure_is_observable_test.go +40 -0
  3. package/cmd/ttscgraph/serve_graph_snapshot_version_matches_typescript_client_test.go +32 -0
  4. package/cmd/ttscgraph/serve_phase_trace_accounts_for_load_failure_test.go +45 -0
  5. package/cmd/ttscgraph/serve_phase_trace_is_opt_in_and_payload_free_test.go +52 -0
  6. package/cmd/ttscgraph/serve_protocol_negotiates_incremental_shards_test.go +44 -0
  7. package/cmd/ttscgraph/serve_session_reloads_commonjs_package_entry_for_esm_test.go +2 -2
  8. package/cmd/ttscgraph/serve_session_reloads_superseding_module_candidates_test.go +46 -20
  9. package/cmd/ttscgraph/serve_session_retries_failed_dump_test.go +9 -9
  10. package/cmd/ttscgraph/serve_shards.go +1390 -0
  11. package/cmd/ttscgraph/serve_shards_abandoned_generation_converges_on_retry_test.go +92 -0
  12. package/cmd/ttscgraph/serve_shards_delete_removed_source_test.go +57 -0
  13. package/cmd/ttscgraph/serve_shards_external_endpoints_test.go +53 -0
  14. package/cmd/ttscgraph/serve_shards_keep_source_distributed_dependencies_at_the_boundary_test.go +108 -0
  15. package/cmd/ttscgraph/serve_shards_match_full_dump_oracle_test.go +88 -0
  16. package/cmd/ttscgraph/serve_shards_merge_pending_and_new_edits_test.go +50 -0
  17. package/cmd/ttscgraph/serve_shards_prune_unreferenced_external_endpoints_test.go +65 -0
  18. package/cmd/ttscgraph/serve_shards_publish_created_source_test.go +92 -0
  19. package/cmd/ttscgraph/serve_shards_rebuild_declaration_edit_test.go +43 -0
  20. package/cmd/ttscgraph/serve_shards_refresh_cross_file_implementation_test.go +63 -0
  21. package/cmd/ttscgraph/serve_shards_refresh_dependent_diagnostics_test.go +80 -0
  22. package/cmd/ttscgraph/serve_shards_rename_moves_source_identity_test.go +95 -0
  23. package/cmd/ttscgraph/serve_shards_retry_preserves_committed_generation_test.go +48 -0
  24. package/cmd/ttscgraph/serve_shards_reuse_unaffected_sources_test.go +121 -0
  25. package/driver/declaration_shape.go +85 -0
  26. package/driver/program.go +39 -10
  27. package/go.mod +1 -1
  28. package/internal/graph/build.go +62 -10
  29. package/internal/graph/build_files_reuses_committed_cross_file_endpoints_test.go +87 -0
  30. package/internal/graph/build_keeps_source_distributed_dependencies_at_the_external_boundary_test.go +74 -0
  31. package/internal/graph/dump.go +84 -43
  32. package/internal/graph/dump_path_mapper_caches_alias_resolution_test.go +29 -0
  33. package/internal/graph/dump_paths.go +134 -11
  34. package/internal/graph/dump_paths_canonicalize_windows_short_root_test.go +39 -0
  35. package/internal/graph/dump_rejects_provenance_only_path_errors_test.go +32 -0
  36. package/internal/graph/dump_serializes_the_full_graph_test.go +2 -2
  37. package/internal/graph/dump_symlink_project_publishes_matching_base_test.go +82 -0
  38. package/internal/graph/edges.go +58 -16
  39. package/internal/graph/exports.go +9 -2
  40. package/internal/graph/graph.go +47 -7
  41. package/internal/graph/graph_kind_contracts_match_their_producers_test.go +121 -0
  42. package/internal/graph/node_modifiers_emit_union_strings_test.go +1 -1
  43. package/internal/graph/provenance.go +11 -1
  44. package/internal/graph/resolve.go +1 -4
  45. package/internal/graph/source.go +23 -0
  46. package/internal/graph/wire_node_ids_share_alias_resolution_test.go +29 -0
  47. package/internal/lspserver/lsp_launcher_reload_directory_fingerprint_parity_windows_test.go +94 -0
  48. package/internal/lspserver/lsp_project_input_glob_path_other.go +16 -0
  49. package/internal/lspserver/lsp_project_input_glob_path_windows.go +36 -0
  50. package/internal/lspserver/lsp_project_input_path_key_other.go +17 -0
  51. package/internal/lspserver/lsp_project_input_path_key_windows.go +228 -0
  52. package/internal/lspserver/lsp_project_input_path_key_windows_test.go +460 -0
  53. package/internal/lspserver/lsp_project_inputs.go +117 -49
  54. package/internal/lspserver/lsp_reload_directories_separate_glob_territory_test.go +181 -0
  55. package/lib/flags/schema.js +1 -1
  56. package/lib/flags/schema.js.map +1 -1
  57. package/lib/internal/projectInputPathIdentity.d.ts +13 -5
  58. package/lib/internal/projectInputPathIdentity.js +95 -41
  59. package/lib/internal/projectInputPathIdentity.js.map +1 -1
  60. package/lib/internal/windowsDirectoryCaseSensitivity.d.ts +10 -0
  61. package/lib/internal/windowsDirectoryCaseSensitivity.js +34 -0
  62. package/lib/internal/windowsDirectoryCaseSensitivity.js.map +1 -0
  63. package/lib/launcher/internal/runTtsc.js +5 -0
  64. package/lib/launcher/internal/runTtsc.js.map +1 -1
  65. package/lib/launcher/internal/runTtscserver.js +59 -8
  66. package/lib/launcher/internal/runTtscserver.js.map +1 -1
  67. package/lib/launcher/internal/runTtsx.js +61 -7
  68. package/lib/launcher/internal/runTtsx.js.map +1 -1
  69. package/lib/launcher/internal/runtimeHooks.d.ts +6 -5
  70. package/lib/launcher/internal/runtimeHooks.js +7 -15
  71. package/lib/launcher/internal/runtimeHooks.js.map +1 -1
  72. package/lib/launcher/internal/watchTopology.d.ts +82 -8
  73. package/lib/launcher/internal/watchTopology.js +535 -187
  74. package/lib/launcher/internal/watchTopology.js.map +1 -1
  75. package/lib/plugin/internal/descriptorProcessFailure.d.ts +32 -0
  76. package/lib/plugin/internal/descriptorProcessFailure.js +73 -0
  77. package/lib/plugin/internal/descriptorProcessFailure.js.map +1 -0
  78. package/lib/plugin/internal/loadProjectPlugins.js +29 -8
  79. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  80. package/package.json +12 -8
  81. package/src/flags/schema.ts +2 -1
  82. package/src/internal/projectInputPathIdentity.ts +150 -46
  83. package/src/internal/windowsDirectoryCaseSensitivity.ts +33 -0
  84. package/src/launcher/internal/runTtsc.ts +7 -0
  85. package/src/launcher/internal/runTtscserver.ts +81 -11
  86. package/src/launcher/internal/runTtsx.ts +87 -10
  87. package/src/launcher/internal/runtimeHooks.ts +16 -16
  88. package/src/launcher/internal/watchTopology.ts +679 -208
  89. package/src/plugin/internal/descriptorProcessFailure.ts +100 -0
  90. package/src/plugin/internal/loadProjectPlugins.ts +41 -8
  91. package/tools/shim_audit/baseline.json +0 -9
@@ -36,6 +36,7 @@ type WatchTopologyOptions = Pick<
36
36
  type WatchTopologyCallbacks = {
37
37
  onError(location: string, error: unknown): void;
38
38
  onInputChange(change: WatchInputChange): void;
39
+ onProjectInputWatchUnavailable?(roots: readonly string[]): void;
39
40
  onProjectInputWatchRoots?(roots: readonly string[]): void;
40
41
  onTopologyChange(): void;
41
42
  };
@@ -56,6 +57,16 @@ type ResolvedWatchTopology = {
56
57
  reloadFiles: Map<string, string>;
57
58
  };
58
59
 
60
+ type CompilerFileSnapshot = {
61
+ content: string;
62
+ owner: string;
63
+ };
64
+
65
+ type CompilerFileMovement = {
66
+ content: boolean;
67
+ owner: boolean;
68
+ };
69
+
59
70
  export type CompilerDirectoryWatchEventPlan = {
60
71
  changes: string[];
61
72
  rearm: string[];
@@ -73,11 +84,14 @@ export type CompilerDirectoryWatchEventPlan = {
73
84
  export class WatchTopology {
74
85
  private analysisOnly = false;
75
86
  private closed = false;
87
+ private compilerPostRegistrationMembershipRefresh = false;
88
+ private compilerPostRegistrationReconciliationScheduled = false;
89
+ private compilerPostRegistrationSkipUnobservedProjectInputWatchRoots = true;
76
90
  private directories = new Map<string, string>();
77
91
  private directoryWatchers = new Map<string, fs.FSWatcher>();
78
92
  private extraInputs: readonly string[] = [];
79
93
  private extraWatchers = new Map<string, fs.FSWatcher>();
80
- private compilerFileStamps = new Map<string, string>();
94
+ private compilerFileSnapshots = new Map<string, CompilerFileSnapshot>();
81
95
  private files = new Map<string, string>();
82
96
  private fileWatchers = new Map<string, fs.FSWatcher>();
83
97
  private observedDirectories = new Map<string, string>();
@@ -99,10 +113,19 @@ export class WatchTopology {
99
113
  reloadFiles: [],
100
114
  root: "",
101
115
  };
116
+ private projectInputRecoveryScheduled = false;
117
+ private projectInputPostRegistrationReconciliationScheduled = false;
102
118
  private projectInputRejectedWatchRoots = new Set<string>();
119
+ private projectInputRequiredWatchRoots = new Map<string, string>();
120
+ private projectInputUnobservedWatchRoots = new Map<string, string>();
103
121
  private projectInputWatchRoots = new Map<string, string>();
104
122
  private projectInputWatchers = new Map<string, fs.FSWatcher>();
105
123
  private projectInputLinkWatchers = new Map<string, fs.FSWatcher>();
124
+ private projectInputCompilerOutputOverlaps = new WeakMap<
125
+ ProjectInputPathIdentityContext,
126
+ Map<string, boolean>
127
+ >();
128
+ private projectInputCompilerAcknowledgements = new Map<string, string>();
106
129
  private reloadFiles = new Map<string, string>();
107
130
 
108
131
  public constructor(
@@ -112,17 +135,30 @@ export class WatchTopology {
112
135
 
113
136
  /** Re-resolve compiler inputs and notify only when their membership changed. */
114
137
  public refresh(notify: boolean): void {
138
+ this.refreshCompilerInputs(notify, false);
139
+ }
140
+
141
+ private refreshCompilerInputs(
142
+ notify: boolean,
143
+ skipUnobservedProjectInputWatchRoots: boolean,
144
+ ): void {
115
145
  const next = resolveWatchTopology(this.options, this.extraInputs);
116
- const projectInputProgramChange =
146
+ const compilerProgramMembershipChange =
117
147
  next.analysisOnly &&
118
148
  mapsEqual(this.reloadFiles, next.reloadFiles) &&
119
149
  mapsEqual(this.outputFiles, next.outputFiles) &&
120
150
  mapsEqual(this.outputs, next.outputs)
121
- ? projectInputCompilerMembershipChange(
122
- this.projectInputs,
123
- this.files,
124
- next.files,
125
- )
151
+ ? compilerMembershipChange(this.files, next.files)
152
+ : [];
153
+ const projectInputProgramOverlap = projectInputCompilerMembershipChange(
154
+ this.projectInputs,
155
+ compilerProgramMembershipChange,
156
+ );
157
+ const projectInputProgramChange =
158
+ compilerProgramMembershipChange.length !== 0 &&
159
+ projectInputProgramOverlap.length ===
160
+ compilerProgramMembershipChange.length
161
+ ? projectInputProgramOverlap
126
162
  : undefined;
127
163
  const changed =
128
164
  this.analysisOnly !== next.analysisOnly ||
@@ -136,41 +172,118 @@ export class WatchTopology {
136
172
  // Stamp the tracked set as it is resolved, so the first event that cannot
137
173
  // name what changed compares against the state the compiler just saw rather
138
174
  // than against nothing, which would make it nominate everything once.
139
- for (const key of [...this.compilerFileStamps.keys()]) {
140
- if (!next.files.has(key)) this.compilerFileStamps.delete(key);
175
+ for (const key of [...this.compilerFileSnapshots.keys()]) {
176
+ if (!next.files.has(key)) this.compilerFileSnapshots.delete(key);
141
177
  }
142
178
  for (const [key, file] of next.files) {
143
179
  // Only a file with no stamp yet is seeded. Restamping one that already
144
180
  // has a baseline would advance it past a change nobody reported, and the
145
181
  // next unnamed event would then read that change as no change at all.
146
- if (!this.compilerFileStamps.has(key)) {
147
- this.compilerFileStamps.set(key, compilerFileStamp(file));
182
+ if (!this.compilerFileSnapshots.has(key)) {
183
+ this.compilerFileSnapshots.set(key, compilerFileSnapshot(file));
148
184
  }
149
185
  }
150
186
  this.directories = next.directories;
151
187
  this.outputFiles = next.outputFiles;
152
188
  this.outputs = next.outputs;
153
189
  this.reloadFiles = next.reloadFiles;
154
- this.syncFileWatchers();
155
- this.syncDirectoryWatchers();
190
+ for (const key of this.projectInputCompilerAcknowledgements.keys()) {
191
+ if (!next.files.has(key)) {
192
+ this.projectInputCompilerAcknowledgements.delete(key);
193
+ }
194
+ }
195
+ const projectInputProgramReload =
196
+ projectInputProgramOverlap.length === 0
197
+ ? false
198
+ : this.acknowledgeProjectInputCompilerMembership(
199
+ projectInputProgramOverlap,
200
+ );
201
+ const fileWatcherRegistered = this.syncFileWatchers();
202
+ const directoryWatcherRegistered = this.syncDirectoryWatchers();
156
203
  this.syncExtraWatchers();
157
- this.syncProjectInputWatchers();
204
+ this.syncProjectInputWatchers(skipUnobservedProjectInputWatchRoots);
205
+ if (fileWatcherRegistered || directoryWatcherRegistered) {
206
+ this.scheduleCompilerPostRegistrationReconciliation(
207
+ directoryWatcherRegistered,
208
+ skipUnobservedProjectInputWatchRoots,
209
+ );
210
+ }
158
211
  if (notify && changed) {
159
212
  if (projectInputProgramChange !== undefined) {
160
- this.callbacks.onInputChange({
161
- invalidate: true,
162
- kind: "project",
163
- path:
164
- projectInputProgramChange.length === 1
165
- ? projectInputProgramChange[0]
166
- : undefined,
167
- });
213
+ const changedPath =
214
+ projectInputProgramChange.length === 1
215
+ ? projectInputProgramChange[0]
216
+ : undefined;
217
+ this.callbacks.onInputChange(
218
+ projectInputProgramReload
219
+ ? { kind: "config", path: changedPath }
220
+ : {
221
+ invalidate: true,
222
+ kind: "project",
223
+ path: changedPath,
224
+ },
225
+ );
168
226
  } else {
169
227
  this.callbacks.onTopologyChange();
170
228
  }
171
229
  }
172
230
  }
173
231
 
232
+ /**
233
+ * Hand one Program-membership transition from the compiler lane to the
234
+ * overlapping project-input lane.
235
+ *
236
+ * Windows can deliver the compiler membership refresh before the recursive
237
+ * project watcher names the same creation. The rebuild scheduled here already
238
+ * consumes the current project bytes, so publishing their strong fingerprints
239
+ * keeps the later parent event from rediscovering the same population delta.
240
+ * A newly tracked compiler file also remembers that fingerprint until its
241
+ * first named content delivery; identical bytes are the delayed creation,
242
+ * while different bytes are a real later edit and remain observable even
243
+ * inside filesystem timestamp resolution.
244
+ */
245
+ private acknowledgeProjectInputCompilerMembership(
246
+ changed: readonly string[],
247
+ ): boolean {
248
+ const matches = this.collectProjectInputMatches();
249
+ const fingerprints = fingerprintProjectInputMatches(matches);
250
+ const identities = createProjectInputPathIdentityContext();
251
+ const changedInputs = projectInputChangedPaths({
252
+ next: matches,
253
+ nextFingerprints: fingerprints,
254
+ previous: this.projectInputMatches,
255
+ previousFingerprints: this.projectInputFingerprints,
256
+ });
257
+ const population = this.projectInputPopulation();
258
+ const causedBy = projectInputCompilerMembershipProjectChanges(
259
+ changed,
260
+ population.globs,
261
+ );
262
+ const reload = projectInputReloadEventShouldNotify({
263
+ causedBy,
264
+ changed: causedBy.length === 1 ? causedBy[0] : undefined,
265
+ changedInputs,
266
+ globs: population.globs,
267
+ reloadDirectories: population.reloadDirectories,
268
+ reloadFiles: population.reloadFiles ?? [],
269
+ });
270
+ // The callback below consumes the complete population observed by this
271
+ // scan. Crucially, reload classification runs against the old baseline
272
+ // first, so a concurrent selection delta becomes one cold transition
273
+ // instead of disappearing behind the warm compiler-membership handoff.
274
+ this.projectInputMatches = matches;
275
+ this.projectInputFingerprints = fingerprints;
276
+ for (const location of changed) {
277
+ const compilerKey = pathKey(location);
278
+ if (!this.files.has(compilerKey)) continue;
279
+ const fingerprint = fingerprints.get(identities.resolve(location).key);
280
+ if (fingerprint !== undefined && fingerprint !== "") {
281
+ this.projectInputCompilerAcknowledgements.set(compilerKey, fingerprint);
282
+ }
283
+ }
284
+ return reload;
285
+ }
286
+
174
287
  /** Add Go plugin source trees discovered by the real build lane. */
175
288
  public setExtraInputs(inputs: readonly string[]): void {
176
289
  const next = uniqueExistingPaths(inputs);
@@ -199,29 +312,11 @@ export class WatchTopology {
199
312
  }
200
313
  this.projectInputs = next;
201
314
  this.projectInputRejectedWatchRoots.clear();
202
- const declarations = new Set(
203
- [
204
- next,
205
- inputs,
206
- { ...(inputs.declared ?? inputs), root: inputs.root },
207
- ].flatMap((snapshot) => [
208
- ...snapshot.files.map((file) =>
209
- projectInputDeclarationKey("file", file),
210
- ),
211
- ...snapshot.globs.map((glob) =>
212
- projectInputDeclarationKey("glob", glob),
213
- ),
214
- ...(snapshot.reloadFiles ?? []).map((file) =>
215
- projectInputDeclarationKey("reload", file),
216
- ),
217
- ...(snapshot.reloadDirectories ?? []).map((directory) =>
218
- projectInputDeclarationKey("reload-directory", directory),
219
- ),
220
- ]),
221
- );
222
- for (const key of this.projectInputWatchRoots.keys()) {
223
- if (!declarations.has(key)) this.projectInputWatchRoots.delete(key);
224
- }
315
+ this.pruneProjectInputWatchRoots([
316
+ next,
317
+ inputs,
318
+ { ...(inputs.declared ?? inputs), root: inputs.root },
319
+ ]);
225
320
  this.projectInputMatches = this.collectProjectInputMatches();
226
321
  this.projectInputFingerprints = fingerprintProjectInputMatches(
227
322
  this.projectInputMatches,
@@ -239,9 +334,16 @@ export class WatchTopology {
239
334
  closeWatchers(this.projectInputLinkWatchers);
240
335
  }
241
336
 
242
- private syncFileWatchers(): void {
337
+ private syncFileWatchers(skipMissing = false): boolean {
338
+ const previous = new Map(this.fileWatchers);
243
339
  const files =
244
- process.platform === "win32" ? new Map<string, string>() : this.files;
340
+ process.platform === "win32"
341
+ ? new Map<string, string>()
342
+ : skipMissing
343
+ ? new Map(
344
+ [...this.files].filter(([, location]) => fs.existsSync(location)),
345
+ )
346
+ : this.files;
245
347
  syncWatchers(
246
348
  this.fileWatchers,
247
349
  files,
@@ -254,7 +356,9 @@ export class WatchTopology {
254
356
  // receives, and it carries no filename to distinguish an edit from
255
357
  // a touch. It answers the same question the unnamed directory event
256
358
  // answers, so it answers it the same way: from the bytes.
257
- if (!this.compilerFileMoved(location)) return;
359
+ const movement = this.compilerFileMovement(location);
360
+ if (movement.owner) this.rearmFileWatchers([location], true);
361
+ if (!movement.content) return;
258
362
  this.callbacks.onInputChange({
259
363
  kind: this.classifyCompilerInput(location),
260
364
  path: location,
@@ -262,19 +366,27 @@ export class WatchTopology {
262
366
  },
263
367
  ),
264
368
  (location, error) => this.callbacks.onError(location, error),
369
+ () => this.closed === false,
370
+ );
371
+ return [...this.fileWatchers].some(
372
+ ([key, watcher]) => previous.get(key) !== watcher,
265
373
  );
266
374
  }
267
375
 
268
- /** Whether a tracked file's bytes differ from the last stamp taken. */
269
- private compilerFileMoved(location: string): boolean {
376
+ /** Compare a tracked file's content and physical owner with its snapshot. */
377
+ private compilerFileMovement(location: string): CompilerFileMovement {
270
378
  const key = pathKey(location);
271
- const stamp = compilerFileStamp(location);
272
- if (this.compilerFileStamps.get(key) === stamp) return false;
273
- this.compilerFileStamps.set(key, stamp);
274
- return true;
379
+ const previous = this.compilerFileSnapshots.get(key);
380
+ const next = compilerFileSnapshot(location);
381
+ this.compilerFileSnapshots.set(key, next);
382
+ return {
383
+ content: previous?.content !== next.content,
384
+ owner: previous?.owner !== next.owner,
385
+ };
275
386
  }
276
387
 
277
- private syncDirectoryWatchers(): void {
388
+ private syncDirectoryWatchers(): boolean {
389
+ const previous = new Map(this.directoryWatchers);
278
390
  const desired = new Map(this.directories);
279
391
  for (const [key, location] of this.observedDirectories) {
280
392
  if (
@@ -345,9 +457,89 @@ export class WatchTopology {
345
457
  },
346
458
  ),
347
459
  (location, error) => this.callbacks.onError(location, error),
460
+ () => this.closed === false,
461
+ );
462
+ return [...this.directoryWatchers].some(
463
+ ([key, watcher]) => previous.get(key) !== watcher,
348
464
  );
349
465
  }
350
466
 
467
+ /**
468
+ * Reconcile tracked compiler files after a newly registered watcher returns.
469
+ *
470
+ * A file or directory watcher can be returned before its backend is ready to
471
+ * deliver the first event. The compiler-file stamps were captured before
472
+ * registration, so one coalesced microtask can recover a change in that
473
+ * handoff window. A real event updates the same stamp first and makes this
474
+ * bounded scan a no-op.
475
+ */
476
+ private scheduleCompilerPostRegistrationReconciliation(
477
+ refreshMembership: boolean,
478
+ skipUnobservedProjectInputWatchRoots: boolean,
479
+ ): void {
480
+ if (this.closed) return;
481
+ if (refreshMembership) {
482
+ this.compilerPostRegistrationMembershipRefresh = true;
483
+ this.compilerPostRegistrationSkipUnobservedProjectInputWatchRoots =
484
+ this.compilerPostRegistrationSkipUnobservedProjectInputWatchRoots &&
485
+ skipUnobservedProjectInputWatchRoots;
486
+ }
487
+ if (this.compilerPostRegistrationReconciliationScheduled) return;
488
+ this.compilerPostRegistrationReconciliationScheduled = true;
489
+ queueMicrotask(() => {
490
+ this.compilerPostRegistrationReconciliationScheduled = false;
491
+ if (this.closed) return;
492
+ const refreshCompilerMembership =
493
+ this.compilerPostRegistrationMembershipRefresh;
494
+ const skipUnobservedProjectInputWatchRoots =
495
+ this.compilerPostRegistrationSkipUnobservedProjectInputWatchRoots;
496
+ this.compilerPostRegistrationMembershipRefresh = false;
497
+ this.compilerPostRegistrationSkipUnobservedProjectInputWatchRoots = true;
498
+
499
+ const changed: string[] = [];
500
+ const rearm: string[] = [];
501
+ for (const file of this.files.values()) {
502
+ const movement = this.compilerFileMovement(file);
503
+ if (movement.content) changed.push(file);
504
+ if (movement.owner) rearm.push(file);
505
+ }
506
+ // A replacement can move the path to a new inode without changing its
507
+ // cheap content stamp or topology key. Rebind its physical owner without
508
+ // inventing a content notification. Missing entries remain covered by
509
+ // their parent directory and are retried when recreation is observed.
510
+ this.rearmFileWatchers(rearm, true);
511
+ for (const file of changed) {
512
+ this.callbacks.onInputChange({
513
+ kind: this.classifyCompilerInput(file),
514
+ path: file,
515
+ });
516
+ }
517
+ if (!refreshCompilerMembership) return;
518
+ try {
519
+ // Directory watchers own files not present in the current Program.
520
+ // Re-resolve even when every tracked stamp is unchanged so a swallowed
521
+ // startup event cannot strand a newly included source.
522
+ this.refreshCompilerInputs(true, skipUnobservedProjectInputWatchRoots);
523
+ } catch (error) {
524
+ const reported = new Set(changed.map(pathKey));
525
+ const reconciledChange = changed.length === 1 ? changed[0]! : undefined;
526
+ for (const reload of reloadInputsForFailedTopologyRefresh(
527
+ this.reloadFiles.values(),
528
+ reconciledChange,
529
+ )) {
530
+ if (reported.has(pathKey(reload))) continue;
531
+ this.callbacks.onInputChange({ kind: "config", path: reload });
532
+ }
533
+ this.callbacks.onError(
534
+ reconciledChange === undefined
535
+ ? (this.options.projectRoot ?? this.options.cwd)
536
+ : path.dirname(reconciledChange),
537
+ error,
538
+ );
539
+ }
540
+ });
541
+ }
542
+
351
543
  /**
352
544
  * Narrow a plan's changes to the tracked files that actually moved.
353
545
  *
@@ -374,23 +566,36 @@ export class WatchTopology {
374
566
  // Those two are decided from the bytes, and the rearm they drive is
375
567
  // unaffected, because rebinding is about the inode and not the content.
376
568
  if (changed !== undefined && event !== "rename") {
377
- for (const file of changes) this.recordCompilerFileStamp(file);
378
- return [...changes];
569
+ return changes.filter((file) => {
570
+ const key = pathKey(file);
571
+ const acknowledged = this.projectInputCompilerAcknowledgements.get(key);
572
+ this.projectInputCompilerAcknowledgements.delete(key);
573
+ this.recordCompilerFileSnapshot(file);
574
+ return (
575
+ acknowledged === undefined ||
576
+ acknowledged !== fingerprintProjectInputFile(file)
577
+ );
578
+ });
379
579
  }
380
- return changes.filter((file) => this.compilerFileMoved(file));
580
+ return changes.filter((file) => this.compilerFileMovement(file).content);
381
581
  }
382
582
 
383
- private recordCompilerFileStamp(file: string): void {
384
- this.compilerFileStamps.set(pathKey(file), compilerFileStamp(file));
583
+ private recordCompilerFileSnapshot(file: string): void {
584
+ this.compilerFileSnapshots.set(pathKey(file), compilerFileSnapshot(file));
385
585
  }
386
586
 
387
- private rearmFileWatchers(files: readonly string[]): void {
587
+ private rearmFileWatchers(
588
+ files: readonly string[],
589
+ skipMissing = false,
590
+ ): void {
388
591
  for (const file of files) {
389
592
  const key = pathKey(file);
390
593
  this.fileWatchers.get(key)?.close();
391
594
  this.fileWatchers.delete(key);
392
595
  }
393
- this.syncFileWatchers();
596
+ if (this.syncFileWatchers(skipMissing)) {
597
+ this.scheduleCompilerPostRegistrationReconciliation(false, true);
598
+ }
394
599
  }
395
600
 
396
601
  private syncExtraWatchers(): void {
@@ -419,13 +624,19 @@ export class WatchTopology {
419
624
  },
420
625
  ),
421
626
  (location, error) => this.callbacks.onError(location, error),
627
+ () => this.closed === false,
422
628
  );
423
629
  }
424
630
 
425
- private syncProjectInputWatchers(): void {
631
+ private syncProjectInputWatchers(
632
+ skipUnobservedProjectInputWatchRoots = false,
633
+ ): void {
426
634
  if (this.closed) return;
635
+ const previous = new Map(this.projectInputWatchers);
636
+ const previousLinks = new Map(this.projectInputLinkWatchers);
427
637
  const identities = createProjectInputPathIdentityContext();
428
638
  const desired = new Map<string, string>();
639
+ const required = new Map<string, string>();
429
640
  for (const file of this.projectInputDeclarations("file")) {
430
641
  if (this.isProjectInputCompilerOutput(file, identities)) continue;
431
642
  const location = this.projectInputWatchRoot(
@@ -433,9 +644,14 @@ export class WatchTopology {
433
644
  file,
434
645
  path.dirname(file),
435
646
  );
436
- if (location !== undefined) {
437
- this.retainProjectInputWatchRoot(desired, identities, location);
438
- }
647
+ this.retainProjectInputWatchRoot(
648
+ required,
649
+ desired,
650
+ identities,
651
+ location,
652
+ path.dirname(file),
653
+ skipUnobservedProjectInputWatchRoots,
654
+ );
439
655
  }
440
656
  for (const glob of this.projectInputDeclarations("glob")) {
441
657
  const root = literalGlobRoot(glob);
@@ -443,9 +659,14 @@ export class WatchTopology {
443
659
  continue;
444
660
  }
445
661
  const location = this.projectInputWatchRoot("glob", glob, root);
446
- if (location !== undefined) {
447
- this.retainProjectInputWatchRoot(desired, identities, location);
448
- }
662
+ this.retainProjectInputWatchRoot(
663
+ required,
664
+ desired,
665
+ identities,
666
+ location,
667
+ root,
668
+ skipUnobservedProjectInputWatchRoots,
669
+ );
449
670
  }
450
671
  for (const file of this.projectInputDeclarations("reload")) {
451
672
  if (this.isProjectInputCompilerOutput(file, identities)) continue;
@@ -454,9 +675,14 @@ export class WatchTopology {
454
675
  file,
455
676
  path.dirname(file),
456
677
  );
457
- if (location !== undefined) {
458
- this.retainProjectInputWatchRoot(desired, identities, location);
459
- }
678
+ this.retainProjectInputWatchRoot(
679
+ required,
680
+ desired,
681
+ identities,
682
+ location,
683
+ path.dirname(file),
684
+ skipUnobservedProjectInputWatchRoots,
685
+ );
460
686
  }
461
687
  for (const directory of this.projectInputDeclarations("reload-directory")) {
462
688
  if (this.isProjectInputCompilerOutputDirectory(directory, identities)) {
@@ -467,9 +693,14 @@ export class WatchTopology {
467
693
  directory,
468
694
  directory,
469
695
  );
470
- if (location !== undefined) {
471
- this.retainProjectInputWatchRoot(desired, identities, location);
472
- }
696
+ this.retainProjectInputWatchRoot(
697
+ required,
698
+ desired,
699
+ identities,
700
+ location,
701
+ directory,
702
+ skipUnobservedProjectInputWatchRoots,
703
+ );
473
704
  }
474
705
  const active = new Map<string, string>();
475
706
  for (const location of projectInputActiveWatchDirectories(
@@ -479,6 +710,7 @@ export class WatchTopology {
479
710
  const identity = identities.resolve(location);
480
711
  active.set(identity.key, identity.path);
481
712
  }
713
+ this.projectInputRequiredWatchRoots = required;
482
714
  syncWatchers(
483
715
  this.projectInputWatchers,
484
716
  active,
@@ -499,14 +731,31 @@ export class WatchTopology {
499
731
  const firstFailure = !this.projectInputRejectedWatchRoots.has(key);
500
732
  this.projectInputRejectedWatchRoots.add(key);
501
733
  this.callbacks.onError(location, error);
502
- if (firstFailure) {
503
- queueMicrotask(() => this.syncProjectInputWatchers());
734
+ if (firstFailure && !this.closed) {
735
+ this.scheduleProjectInputWatcherRecovery();
504
736
  }
505
737
  },
738
+ () => this.closed === false,
506
739
  );
740
+ if (this.closed) return;
741
+ if (!this.projectInputRecoveryScheduled) {
742
+ this.reportUnobservedProjectInputWatchRoots(required);
743
+ }
507
744
  this.syncProjectInputLinkWatchers(identities);
745
+ const watcherRegistered =
746
+ [...this.projectInputWatchers].some(
747
+ ([key, watcher]) => previous.get(key) !== watcher,
748
+ ) ||
749
+ [...this.projectInputLinkWatchers].some(
750
+ ([key, watcher]) => previousLinks.get(key) !== watcher,
751
+ );
752
+ if (watcherRegistered) {
753
+ this.scheduleProjectInputPostRegistrationReconciliation();
754
+ }
508
755
  this.callbacks.onProjectInputWatchRoots?.(
509
- [...this.projectInputWatchers.keys()].sort(),
756
+ [...this.projectInputWatchers.keys()]
757
+ .map((key) => active.get(key) ?? identities.resolve(key).path)
758
+ .sort(),
510
759
  );
511
760
  }
512
761
 
@@ -557,6 +806,7 @@ export class WatchTopology {
557
806
  },
558
807
  ),
559
808
  (location, error) => this.callbacks.onError(location, error),
809
+ () => this.closed === false,
560
810
  );
561
811
  }
562
812
 
@@ -582,10 +832,31 @@ export class WatchTopology {
582
832
  }
583
833
 
584
834
  private retainProjectInputWatchRoot(
835
+ required: Map<string, string>,
585
836
  desired: Map<string, string>,
586
837
  identities: ProjectInputPathIdentityContext,
587
- location: string,
838
+ location: string | undefined,
839
+ target: string,
840
+ skipUnobservedProjectInputWatchRoots: boolean,
588
841
  ): void {
842
+ const requiredIdentity = identities.resolve(location ?? target);
843
+ required.set(requiredIdentity.key, requiredIdentity.path);
844
+ if (skipUnobservedProjectInputWatchRoots) {
845
+ const retainedActiveRoot = [...this.projectInputWatchers.keys()].find(
846
+ (root) => isProjectInputPathIdentityWithin(root, requiredIdentity.key),
847
+ );
848
+ if (retainedActiveRoot !== undefined) {
849
+ desired.set(retainedActiveRoot, retainedActiveRoot);
850
+ return;
851
+ }
852
+ }
853
+ if (
854
+ location === undefined ||
855
+ (skipUnobservedProjectInputWatchRoots &&
856
+ this.projectInputUnobservedWatchRoots.has(requiredIdentity.key))
857
+ ) {
858
+ return;
859
+ }
589
860
  const available = projectInputAvailableWatchDirectory(
590
861
  location,
591
862
  this.projectInputRejectedWatchRoots,
@@ -597,6 +868,130 @@ export class WatchTopology {
597
868
  desired.set(identity.key, identity.path);
598
869
  }
599
870
 
871
+ /**
872
+ * Retry a failed root on the next reconciliation instead of retiring it for
873
+ * the session.
874
+ *
875
+ * This immediate recovery pass still honors the rejected root so it can
876
+ * install a safe ancestor where one exists. Only the recovery fixpoint
877
+ * reports a genuinely uncovered lane; transient gaps between fallback
878
+ * candidates are not user-visible. The rejection then expires. A later
879
+ * compiler refresh or an unchanged project-input republication can retry the
880
+ * original root, while a permanently failing backend costs at most one
881
+ * attempt per sync.
882
+ */
883
+ private scheduleProjectInputWatcherRecovery(): void {
884
+ if (this.projectInputRecoveryScheduled) return;
885
+ this.projectInputRecoveryScheduled = true;
886
+ queueMicrotask(() => {
887
+ try {
888
+ let previousRejectionCount = -1;
889
+ while (
890
+ this.closed === false &&
891
+ previousRejectionCount !== this.projectInputRejectedWatchRoots.size
892
+ ) {
893
+ previousRejectionCount = this.projectInputRejectedWatchRoots.size;
894
+ this.syncProjectInputWatchers();
895
+ }
896
+ } finally {
897
+ this.projectInputRejectedWatchRoots.clear();
898
+ this.projectInputRecoveryScheduled = false;
899
+ if (!this.closed) {
900
+ this.reportUnobservedProjectInputWatchRoots(
901
+ this.projectInputRequiredWatchRoots,
902
+ );
903
+ }
904
+ }
905
+ });
906
+ }
907
+
908
+ /**
909
+ * Reconcile the snapshot-to-watcher handoff after the caller's current turn.
910
+ *
911
+ * A recursive watcher can return before its backend is ready to deliver the
912
+ * first event. The publication baseline is necessarily captured before that
913
+ * watcher exists, so an input materialized synchronously after
914
+ * `setProjectInputs()` would otherwise depend entirely on that startup event.
915
+ * The ordinary fingerprint update makes this scan and a real backend event
916
+ * race safely: whichever arrives first records the new population and the
917
+ * other becomes a no-op.
918
+ */
919
+ private scheduleProjectInputPostRegistrationReconciliation(): void {
920
+ if (
921
+ this.closed ||
922
+ this.projectInputPostRegistrationReconciliationScheduled
923
+ ) {
924
+ return;
925
+ }
926
+ this.projectInputPostRegistrationReconciliationScheduled = true;
927
+ queueMicrotask(() => {
928
+ this.projectInputPostRegistrationReconciliationScheduled = false;
929
+ if (this.closed) return;
930
+ this.refreshPublishedProjectInputIdentities();
931
+ this.refreshProjectInputs(this.projectInputs.root, undefined, true);
932
+ });
933
+ }
934
+
935
+ /**
936
+ * Re-resolve declarations after watcher registration.
937
+ *
938
+ * A missing path can become a symlink before the handoff scan. The retained
939
+ * normalized snapshot still names the pre-link spelling in that case, so a
940
+ * scan can find the first target file without installing the physical owner
941
+ * that must observe later target changes.
942
+ */
943
+ private refreshPublishedProjectInputIdentities(): void {
944
+ const next = normalizeProjectInputSnapshot(this.declaredProjectInputs);
945
+ if (projectInputSnapshotsEqual(this.projectInputs, next)) return;
946
+ this.projectInputs = next;
947
+ this.pruneProjectInputWatchRoots([next, this.declaredProjectInputs]);
948
+ }
949
+
950
+ /** Drop retained owner choices for declarations no longer published. */
951
+ private pruneProjectInputWatchRoots(
952
+ snapshots: readonly ITtscProjectInputSnapshot[],
953
+ ): void {
954
+ const declarations = new Set(
955
+ snapshots.flatMap((snapshot) => [
956
+ ...snapshot.files.map((file) =>
957
+ projectInputDeclarationKey("file", file),
958
+ ),
959
+ ...snapshot.globs.map((glob) =>
960
+ projectInputDeclarationKey("glob", glob),
961
+ ),
962
+ ...(snapshot.reloadFiles ?? []).map((file) =>
963
+ projectInputDeclarationKey("reload", file),
964
+ ),
965
+ ...(snapshot.reloadDirectories ?? []).map((directory) =>
966
+ projectInputDeclarationKey("reload-directory", directory),
967
+ ),
968
+ ]),
969
+ );
970
+ for (const key of this.projectInputWatchRoots.keys()) {
971
+ if (!declarations.has(key)) this.projectInputWatchRoots.delete(key);
972
+ }
973
+ }
974
+
975
+ /** Report only newly uncovered project-input roots as an observation loss. */
976
+ private reportUnobservedProjectInputWatchRoots(
977
+ required: ReadonlyMap<string, string>,
978
+ ): void {
979
+ const active = [...this.projectInputWatchers.keys()];
980
+ const unavailable = new Map(
981
+ [...required].filter(([key]) =>
982
+ active.every((root) => !isProjectInputPathIdentityWithin(root, key)),
983
+ ),
984
+ );
985
+ const newlyUnavailable = [...unavailable]
986
+ .filter(([key]) => !this.projectInputUnobservedWatchRoots.has(key))
987
+ .map(([, location]) => location)
988
+ .sort();
989
+ this.projectInputUnobservedWatchRoots = unavailable;
990
+ if (newlyUnavailable.length !== 0) {
991
+ this.callbacks.onProjectInputWatchUnavailable?.(newlyUnavailable);
992
+ }
993
+ }
994
+
600
995
  /**
601
996
  * One snapshot holding every spelling of every declaration.
602
997
  *
@@ -669,7 +1064,11 @@ export class WatchTopology {
669
1064
  return resolved;
670
1065
  }
671
1066
 
672
- private refreshProjectInputs(location: string, changed?: string): void {
1067
+ private refreshProjectInputs(
1068
+ location: string,
1069
+ changed?: string,
1070
+ skipUnobservedProjectInputWatchRoots = false,
1071
+ ): void {
673
1072
  try {
674
1073
  const previous = this.projectInputMatches;
675
1074
  const identities = createProjectInputPathIdentityContext();
@@ -725,19 +1124,21 @@ export class WatchTopology {
725
1124
  previous,
726
1125
  previousFingerprints: this.projectInputFingerprints,
727
1126
  });
1127
+ const reconciledChange =
1128
+ changed ?? (changedInputs.length === 1 ? changedInputs[0] : undefined);
728
1129
  // Both spellings classify the event. The normalized form names the file a
729
1130
  // link pointed at when the snapshot was published, so after a retarget it
730
1131
  // names the wrong one; only the declared form resolves to what the link
731
1132
  // points at now, which is the selection this lane exists to protect.
732
1133
  const reload = projectInputReloadEventShouldNotify({
733
- changed,
1134
+ changed: reconciledChange,
734
1135
  changedInputs,
735
1136
  globs: population.globs,
736
1137
  reloadDirectories: population.reloadDirectories ?? [],
737
1138
  reloadFiles: population.reloadFiles ?? [],
738
1139
  });
739
1140
  const invalidate = projectInputMembershipInvalidatesProgram({
740
- changed,
1141
+ changed: reconciledChange,
741
1142
  changedInputs,
742
1143
  contentChanged,
743
1144
  next,
@@ -745,28 +1146,31 @@ export class WatchTopology {
745
1146
  });
746
1147
  this.projectInputMatches = next;
747
1148
  this.projectInputFingerprints = nextFingerprints;
748
- this.syncProjectInputWatchers();
1149
+ this.syncProjectInputWatchers(skipUnobservedProjectInputWatchRoots);
749
1150
  // A JSON/TS/JS project-input member can simultaneously enter or leave
750
1151
  // the compiler Program. Reconcile the compiler watch snapshot before
751
1152
  // scheduling its resident invalidation, so runWatch's post-cycle refresh
752
1153
  // does not rediscover the same delta as a broader execution reload.
753
- if (invalidate) this.refresh(false);
1154
+ if (invalidate) {
1155
+ this.refreshCompilerInputs(false, skipUnobservedProjectInputWatchRoots);
1156
+ }
754
1157
  if (
755
1158
  projectInputEventShouldNotify({
756
1159
  contentChanged,
757
1160
  directlyMatched,
758
1161
  membershipChanged,
759
1162
  }) &&
760
- (changed === undefined ||
761
- this.isProjectInputCompilerOutput(changed, identities) === false)
1163
+ (reconciledChange === undefined ||
1164
+ this.isProjectInputCompilerOutput(reconciledChange, identities) ===
1165
+ false)
762
1166
  ) {
763
1167
  this.callbacks.onInputChange(
764
1168
  reload
765
- ? { kind: "config", path: changed }
1169
+ ? { kind: "config", path: reconciledChange }
766
1170
  : {
767
1171
  ...(invalidate ? { invalidate: true } : {}),
768
1172
  kind: "project",
769
- path: changed,
1173
+ path: reconciledChange,
770
1174
  },
771
1175
  );
772
1176
  }
@@ -775,7 +1179,7 @@ export class WatchTopology {
775
1179
  // replacement is readable. Rebind ancestor ownership even when the
776
1180
  // population scan races that transient gap, so a later create cannot be
777
1181
  // stranded without a watcher.
778
- this.syncProjectInputWatchers();
1182
+ this.syncProjectInputWatchers(skipUnobservedProjectInputWatchRoots);
779
1183
  this.callbacks.onError(location, error);
780
1184
  }
781
1185
  }
@@ -894,7 +1298,13 @@ export class WatchTopology {
894
1298
  identities: ProjectInputPathIdentityContext,
895
1299
  ): boolean {
896
1300
  const root = this.projectInputs.root;
1301
+ let overlaps = this.projectInputCompilerOutputOverlaps.get(identities);
1302
+ if (overlaps === undefined) {
1303
+ overlaps = new Map();
1304
+ this.projectInputCompilerOutputOverlaps.set(identities, overlaps);
1305
+ }
897
1306
  return [...this.outputs.values()].some((output) => {
1307
+ if (!identities.isWithin(output, location)) return false;
898
1308
  // An output directory that is the project itself, or holds it, is not a
899
1309
  // place where only build products live -- the sources are there too. A
900
1310
  // project emitting in place declares exactly that, and honouring it
@@ -903,7 +1313,15 @@ export class WatchTopology {
903
1313
  // is the safe side here: a product that gets watched costs an extra
904
1314
  // rebuild, while an input that does not is never seen again.
905
1315
  if (root !== "" && identities.isWithin(output, root)) return false;
906
- return identities.isWithin(output, location);
1316
+ let overlapsCompilerInput = overlaps.get(output);
1317
+ if (overlapsCompilerInput === undefined) {
1318
+ overlapsCompilerInput = [...this.files.values()].some((input) =>
1319
+ identities.isWithin(output, input),
1320
+ );
1321
+ overlaps.set(output, overlapsCompilerInput);
1322
+ }
1323
+ if (overlapsCompilerInput) return false;
1324
+ return true;
907
1325
  });
908
1326
  }
909
1327
 
@@ -1035,7 +1453,7 @@ function resolveWatchTopology(
1035
1453
  addPaths(outputFiles, compilerOutputs.files);
1036
1454
  addPaths(
1037
1455
  outputFiles,
1038
- inferAdjacentCompilerOutputs(project, options, compilerInputs),
1456
+ inferPerSourceCompilerOutputs(project, options, compilerInputs),
1039
1457
  );
1040
1458
  addPaths(outputs, compilerOutputs.directories);
1041
1459
  addPaths(files, compilerInputs);
@@ -1144,13 +1562,16 @@ function listCompilerInputs(
1144
1562
  `ttsc: failed to list compiler inputs: ${result.error.message}`,
1145
1563
  );
1146
1564
  }
1147
- const outputs = resolveCompilerOutputs(project, options);
1565
+ // `--listFilesOnly` is the authority for compiler inputs. A path may also be
1566
+ // a predicted product of another input, but that collision does not revoke
1567
+ // its Program membership—especially while the compiler is reporting an
1568
+ // overwrite diagnostic. Product inference is used only to classify future
1569
+ // filesystem events, never to subtract from the compiler's answer.
1148
1570
  const inputs = outputText(result.stdout)
1149
1571
  .split(/\r?\n/)
1150
1572
  .map((line) => line.trim())
1151
1573
  .filter((line) => path.isAbsolute(line))
1152
- .map((line) => path.resolve(line))
1153
- .filter((file) => isCompilerOutput(file, outputs) === false);
1574
+ .map((line) => path.resolve(line));
1154
1575
  if (result.status !== 0 && inputs.length === 0) {
1155
1576
  throw new Error(
1156
1577
  `ttsc: failed to list compiler inputs:\n${outputText(result.stderr) || outputText(result.stdout)}`,
@@ -1175,17 +1596,6 @@ function resolveCompilerOutputs(
1175
1596
  if (emit.declaration && emit.declarationDir !== undefined) {
1176
1597
  directories.add(emit.declarationDir);
1177
1598
  }
1178
- if (emit.outFile !== undefined) {
1179
- if (emit.javascript) {
1180
- files.add(emit.outFile);
1181
- if (emit.sourceMap) files.add(`${emit.outFile}.map`);
1182
- }
1183
- if (emit.declaration) {
1184
- const declaration = replaceOutputExtension(emit.outFile, ".d.ts");
1185
- files.add(declaration);
1186
- if (emit.declarationMap) files.add(`${declaration}.map`);
1187
- }
1188
- }
1189
1599
  if (emit.incremental) {
1190
1600
  files.add(defaultTsBuildInfoFile(project, emit));
1191
1601
  }
@@ -1195,54 +1605,82 @@ function resolveCompilerOutputs(
1195
1605
  };
1196
1606
  }
1197
1607
 
1198
- function inferAdjacentCompilerOutputs(
1608
+ function inferPerSourceCompilerOutputs(
1199
1609
  project: ITtscParsedProjectConfig,
1200
1610
  options: WatchTopologyOptions,
1201
1611
  inputs: readonly string[],
1202
1612
  ): string[] {
1203
1613
  const emit = effectiveCompilerEmit(project, options);
1204
1614
  const outputs = new Set<string>();
1615
+ // TypeScript-Go 7 requires an explicit rootDir when output layout would
1616
+ // otherwise need inference. While reporting TS5011 it still emits relative
1617
+ // to the config directory, so the watch model must use that same recovery
1618
+ // layout rather than the legacy common-source-directory rule.
1619
+ const sourceRoot = emit.rootDir ?? project.root;
1205
1620
  for (const input of inputs) {
1206
1621
  const extension = path.extname(input).toLowerCase();
1207
- if (!isCompilerEmittableSourceExtension(extension)) {
1208
- continue;
1209
- }
1210
1622
  if (/\.d\.(?:ts|mts|cts)$/i.test(input)) continue;
1211
1623
  const stem = input.slice(0, -extension.length);
1624
+ const mappedStem = (
1625
+ directory: string | undefined,
1626
+ adjacentWhenOutside: boolean,
1627
+ ): string | undefined => {
1628
+ if (directory === undefined) return stem;
1629
+ if (!isPathWithin(sourceRoot, input)) {
1630
+ return adjacentWhenOutside ? stem : undefined;
1631
+ }
1632
+ return path.resolve(directory, path.relative(sourceRoot, stem));
1633
+ };
1634
+ if (extension === ".json") {
1635
+ if (
1636
+ emit.javascript &&
1637
+ emit.resolveJsonModule &&
1638
+ emit.outDir !== undefined
1639
+ ) {
1640
+ const jsonStem = mappedStem(emit.outDir, false);
1641
+ if (jsonStem !== undefined) outputs.add(`${jsonStem}.json`);
1642
+ }
1643
+ continue;
1644
+ }
1645
+ if (!isCompilerEmittableSourceExtension(extension)) continue;
1212
1646
  if (
1213
1647
  emit.javascript &&
1214
- emit.outDir === undefined &&
1215
- emit.outFile === undefined &&
1216
- (!isJavaScriptSourceExtension(extension) ||
1648
+ (emit.outDir !== undefined ||
1649
+ !isJavaScriptSourceExtension(extension) ||
1217
1650
  (extension === ".jsx" && emit.jsx !== "preserve"))
1218
1651
  ) {
1219
1652
  const javascriptExtension =
1220
- extension === ".mts"
1653
+ extension === ".mts" || extension === ".mjs"
1221
1654
  ? ".mjs"
1222
- : extension === ".cts"
1655
+ : extension === ".cts" || extension === ".cjs"
1223
1656
  ? ".cjs"
1224
- : extension === ".tsx" && emit.jsx === "preserve"
1657
+ : (extension === ".tsx" || extension === ".jsx") &&
1658
+ emit.jsx === "preserve"
1225
1659
  ? ".jsx"
1226
1660
  : ".js";
1227
- const javascript = stem + javascriptExtension;
1228
- outputs.add(javascript);
1229
- if (emit.sourceMap) outputs.add(`${javascript}.map`);
1661
+ const javascriptStem = mappedStem(emit.outDir, true);
1662
+ if (javascriptStem !== undefined) {
1663
+ const javascript = javascriptStem + javascriptExtension;
1664
+ outputs.add(javascript);
1665
+ if (emit.sourceMap) outputs.add(`${javascript}.map`);
1666
+ }
1230
1667
  }
1231
- if (
1232
- emit.declaration &&
1233
- emit.outDir === undefined &&
1234
- emit.declarationDir === undefined &&
1235
- emit.outFile === undefined
1236
- ) {
1668
+ if (emit.declaration) {
1237
1669
  const declarationExtension =
1238
1670
  extension === ".mts" || extension === ".mjs"
1239
1671
  ? ".d.mts"
1240
1672
  : extension === ".cts" || extension === ".cjs"
1241
1673
  ? ".d.cts"
1242
1674
  : ".d.ts";
1243
- const declaration = stem + declarationExtension;
1244
- outputs.add(declaration);
1245
- if (emit.declarationMap) outputs.add(`${declaration}.map`);
1675
+ const declarationStem = mappedStem(
1676
+ emit.declarationDir ?? emit.outDir,
1677
+ true,
1678
+ );
1679
+ if (declarationStem !== undefined) {
1680
+ const declaration = declarationStem + declarationExtension;
1681
+ outputs.add(declaration);
1682
+ if (emit.declarationMap) outputs.add(`${declaration}.map`);
1683
+ }
1246
1684
  }
1247
1685
  }
1248
1686
  return [...outputs];
@@ -1273,7 +1711,7 @@ type EffectiveCompilerEmit = {
1273
1711
  javascript: boolean;
1274
1712
  jsx?: unknown;
1275
1713
  outDir?: string;
1276
- outFile?: string;
1714
+ resolveJsonModule: boolean;
1277
1715
  rootDir?: string;
1278
1716
  sourceMap: boolean;
1279
1717
  tsBuildInfoFile?: string;
@@ -1327,7 +1765,6 @@ function effectiveCompilerEmit(
1327
1765
  passthrough,
1328
1766
  "--declarationDir",
1329
1767
  );
1330
- const cliOutFile = passthroughPathOption(passthrough, "--outFile");
1331
1768
  const cliRootDir = passthroughPathOption(passthrough, "--rootDir");
1332
1769
  const cliTsBuildInfoFile = passthroughPathOption(
1333
1770
  passthrough,
@@ -1335,13 +1772,14 @@ function effectiveCompilerEmit(
1335
1772
  );
1336
1773
  const jsx =
1337
1774
  passthroughStringOption(passthrough, "--jsx") ?? compilerOptions.jsx;
1775
+ const compilerCwd = project.root;
1338
1776
  return {
1339
1777
  declaration,
1340
1778
  declarationDir:
1341
1779
  cliDeclarationDir === null
1342
1780
  ? undefined
1343
1781
  : cliDeclarationDir !== undefined
1344
- ? path.resolve(options.cwd, cliDeclarationDir)
1782
+ ? path.resolve(compilerCwd, cliDeclarationDir)
1345
1783
  : typeof compilerOptions.declarationDir === "string"
1346
1784
  ? path.resolve(compilerOptions.declarationDir)
1347
1785
  : undefined,
@@ -1352,25 +1790,20 @@ function effectiveCompilerEmit(
1352
1790
  cliOutDir === null
1353
1791
  ? undefined
1354
1792
  : cliOutDir !== undefined
1355
- ? path.resolve(options.cwd, cliOutDir)
1793
+ ? path.resolve(compilerCwd, cliOutDir)
1356
1794
  : options.outDir !== undefined
1357
1795
  ? path.resolve(options.cwd, options.outDir)
1358
1796
  : typeof compilerOptions.outDir === "string"
1359
1797
  ? path.resolve(compilerOptions.outDir)
1360
1798
  : undefined,
1361
- outFile:
1362
- cliOutFile === null
1363
- ? undefined
1364
- : cliOutFile !== undefined
1365
- ? path.resolve(options.cwd, cliOutFile)
1366
- : typeof compilerOptions.outFile === "string"
1367
- ? path.resolve(compilerOptions.outFile)
1368
- : undefined,
1799
+ resolveJsonModule:
1800
+ passthroughBooleanOption(passthrough, "--resolveJsonModule") ??
1801
+ compilerOptions.resolveJsonModule === true,
1369
1802
  rootDir:
1370
1803
  cliRootDir === null
1371
1804
  ? undefined
1372
1805
  : cliRootDir !== undefined
1373
- ? path.resolve(options.cwd, cliRootDir)
1806
+ ? path.resolve(compilerCwd, cliRootDir)
1374
1807
  : typeof compilerOptions.rootDir === "string"
1375
1808
  ? path.resolve(compilerOptions.rootDir)
1376
1809
  : undefined,
@@ -1379,7 +1812,7 @@ function effectiveCompilerEmit(
1379
1812
  cliTsBuildInfoFile === null
1380
1813
  ? undefined
1381
1814
  : cliTsBuildInfoFile !== undefined
1382
- ? path.resolve(options.cwd, cliTsBuildInfoFile)
1815
+ ? path.resolve(compilerCwd, cliTsBuildInfoFile)
1383
1816
  : typeof compilerOptions.tsBuildInfoFile === "string"
1384
1817
  ? path.resolve(compilerOptions.tsBuildInfoFile)
1385
1818
  : undefined,
@@ -1458,16 +1891,6 @@ function passthroughOptionMatches(token: string, name: string): boolean {
1458
1891
  return resolveFlagSpec(token)?.name === resolveFlagSpec(name)?.name;
1459
1892
  }
1460
1893
 
1461
- function isCompilerOutput(
1462
- file: string,
1463
- outputs: { directories: readonly string[]; files: readonly string[] },
1464
- ): boolean {
1465
- return (
1466
- outputs.files.some((output) => pathKey(output) === pathKey(file)) ||
1467
- outputs.directories.some((directory) => isPathWithin(directory, file))
1468
- );
1469
- }
1470
-
1471
1894
  function collectTopologyDirectories(
1472
1895
  files: Iterable<string>,
1473
1896
  roots: readonly string[],
@@ -1544,9 +1967,14 @@ export function syncWatchers<T extends SynchronizedWatcher>(
1544
1967
  desired: ReadonlyMap<string, string>,
1545
1968
  create: (location: string, key: string) => T,
1546
1969
  onError: (location: string, error: unknown) => void,
1970
+ shouldContinue: () => boolean = () => true,
1547
1971
  ): boolean {
1548
1972
  let complete = true;
1549
1973
  for (const [key, location] of desired) {
1974
+ if (!shouldContinue()) {
1975
+ complete = false;
1976
+ break;
1977
+ }
1550
1978
  if (watchers.has(key)) continue;
1551
1979
  try {
1552
1980
  const watcher = create(location, key);
@@ -1794,18 +2222,22 @@ function projectInputRecursiveWatchRoot(
1794
2222
  }
1795
2223
 
1796
2224
  /**
1797
- * A cheap identity for a tracked file's current bytes.
2225
+ * Cheap content and physical-owner identities for a tracked file.
1798
2226
  *
1799
- * Modification time and size together answer "did this move" without reading
1800
- * the file, which is all the unnamed-event path needs; a file that vanished
1801
- * answers as absent rather than as unchanged.
2227
+ * Modification time and size answer "did the bytes move" without reading the
2228
+ * file. Device and inode answer whether a POSIX per-file watcher still owns the
2229
+ * path. Keeping the answers separate lets an identical atomic replacement
2230
+ * rebind its watcher without inventing a compiler notification.
1802
2231
  */
1803
- function compilerFileStamp(location: string): string {
2232
+ function compilerFileSnapshot(location: string): CompilerFileSnapshot {
1804
2233
  try {
1805
2234
  const stats = fs.statSync(location);
1806
- return `${stats.mtimeMs}:${stats.size}`;
2235
+ return {
2236
+ content: `${stats.mtimeMs}:${stats.size}`,
2237
+ owner: `${stats.dev}:${stats.ino}`,
2238
+ };
1807
2239
  } catch {
1808
- return "";
2240
+ return { content: "", owner: "" };
1809
2241
  }
1810
2242
  }
1811
2243
 
@@ -2002,6 +2434,7 @@ function projectInputChangedPaths(input: {
2002
2434
  * bytes remain quiet before this classifier is observed.
2003
2435
  */
2004
2436
  export function projectInputReloadEventShouldNotify(input: {
2437
+ causedBy?: readonly string[];
2005
2438
  changed?: string;
2006
2439
  changedInputs: readonly string[];
2007
2440
  reloadDirectories?: readonly string[];
@@ -2071,13 +2504,16 @@ export function projectInputReloadEventShouldNotify(input: {
2071
2504
  // resolution moved. A missed reselection is a wrong answer; a spare restart is
2072
2505
  // a slow one.
2073
2506
  const explains = (directory: ProjectInputPathIdentity): boolean => {
2074
- if (input.changed === undefined) return false;
2507
+ const causes =
2508
+ input.causedBy ?? (input.changed === undefined ? [] : [input.changed]);
2075
2509
  // Only an immediate entry can move this directory's digest, so only an
2076
2510
  // immediate entry can account for the delta. Asking whether the event was
2077
2511
  // data somewhere else answers a different directory's question, and one
2078
2512
  // exemption would then cancel every other directory's evidence.
2079
- const parent = identities.resolve(path.dirname(input.changed)).key;
2080
- return parent === directory.key && exemptedFrom(directory, input.changed);
2513
+ return causes.some((location) => {
2514
+ const parent = identities.resolve(path.dirname(location)).key;
2515
+ return parent === directory.key && exemptedFrom(directory, location);
2516
+ });
2081
2517
  };
2082
2518
  const isReloadDirectoryEvent = (location: string): boolean =>
2083
2519
  namesDirectory(location) || holdsAsImmediateEntry(location);
@@ -2154,11 +2590,10 @@ function projectInputPathMayAffectProgram(location: string): boolean {
2154
2590
  return extension === ".json" || isCompilerEmittableSourceExtension(extension);
2155
2591
  }
2156
2592
 
2157
- function projectInputCompilerMembershipChange(
2158
- snapshot: ITtscProjectInputSnapshot,
2593
+ function compilerMembershipChange(
2159
2594
  previous: ReadonlyMap<string, string>,
2160
2595
  next: ReadonlyMap<string, string>,
2161
- ): string[] | undefined {
2596
+ ): string[] {
2162
2597
  const changed = new Map<string, string>();
2163
2598
  for (const [key, location] of previous) {
2164
2599
  if (next.has(key) === false) changed.set(key, location);
@@ -2166,64 +2601,100 @@ function projectInputCompilerMembershipChange(
2166
2601
  for (const [key, location] of next) {
2167
2602
  if (previous.has(key) === false) changed.set(key, location);
2168
2603
  }
2169
- if (
2170
- changed.size === 0 ||
2171
- [...changed.values()].some(
2172
- (location) =>
2173
- matchesProjectInput(snapshot, location) === false ||
2174
- projectInputPathMayAffectProgram(location) === false,
2175
- )
2176
- ) {
2177
- return undefined;
2178
- }
2179
2604
  return [...changed.values()].sort();
2180
2605
  }
2181
2606
 
2607
+ function projectInputCompilerMembershipChange(
2608
+ snapshot: ITtscProjectInputSnapshot,
2609
+ changed: readonly string[],
2610
+ ): string[] {
2611
+ return changed.filter(
2612
+ (location) =>
2613
+ matchesProjectInput(snapshot, location) &&
2614
+ projectInputPathMayAffectProgram(location),
2615
+ );
2616
+ }
2617
+
2618
+ /**
2619
+ * Name a compiler-membership transition as the recursive project watcher does.
2620
+ *
2621
+ * Creating the first member below a missing glob root makes Windows report the
2622
+ * root directory before it reports the member. Reload-directory fingerprints
2623
+ * must see that same causal path: the new root then explains its ancestor's
2624
+ * immediate-entry delta instead of turning ordinary project data into a cold
2625
+ * selection reload. The deepest matching glob owns the most specific delivery.
2626
+ */
2627
+ function projectInputCompilerMembershipProjectChanges(
2628
+ locations: readonly string[],
2629
+ globs: readonly string[],
2630
+ ): string[] {
2631
+ const identities = createProjectInputPathIdentityContext();
2632
+ const roots = globs.map((glob) => identities.resolve(literalGlobRoot(glob)));
2633
+ const changes = new Map<string, string>();
2634
+ for (const location of locations) {
2635
+ const candidate = identities.resolve(location);
2636
+ const root = roots
2637
+ .filter((entry) => identities.isWithin(entry.path, candidate.path))
2638
+ .sort((left, right) => right.path.length - left.path.length)[0];
2639
+ const change = root ?? candidate;
2640
+ changes.set(change.key, change.path);
2641
+ }
2642
+ return [...changes.values()].sort();
2643
+ }
2644
+
2182
2645
  function fingerprintProjectInputMatches(
2183
2646
  matches: ReadonlyMap<string, string>,
2184
2647
  ): Map<string, string> {
2185
2648
  const fingerprints = new Map<string, string>();
2186
2649
  for (const [key, location] of matches) {
2187
- try {
2188
- fingerprints.set(
2189
- key,
2190
- isDirectory(location)
2191
- ? fingerprintProjectInputDirectory(location)
2192
- : crypto
2193
- .createHash("sha256")
2194
- .update(fs.readFileSync(location))
2195
- .digest("hex"),
2196
- );
2197
- } catch {
2198
- fingerprints.set(key, "");
2199
- }
2650
+ fingerprints.set(
2651
+ key,
2652
+ isDirectory(location)
2653
+ ? fingerprintProjectInputDirectory(location)
2654
+ : fingerprintProjectInputFile(location),
2655
+ );
2200
2656
  }
2201
2657
  return fingerprints;
2202
2658
  }
2203
2659
 
2660
+ function fingerprintProjectInputFile(location: string): string {
2661
+ try {
2662
+ return crypto
2663
+ .createHash("sha256")
2664
+ .update(fs.readFileSync(location))
2665
+ .digest("hex");
2666
+ } catch {
2667
+ return "";
2668
+ }
2669
+ }
2670
+
2204
2671
  function fingerprintProjectInputDirectory(location: string): string {
2205
- const entries = fs
2206
- .readdirSync(location, { withFileTypes: true })
2207
- .map((entry) => {
2208
- const kind = entry.isDirectory()
2209
- ? "directory"
2210
- : entry.isFile()
2211
- ? "file"
2212
- : entry.isSymbolicLink()
2213
- ? "symlink"
2214
- : "other";
2215
- let target = "";
2216
- if (entry.isSymbolicLink()) {
2217
- try {
2218
- target = fs.readlinkSync(path.join(location, entry.name));
2219
- } catch {
2220
- target = "<unreadable>";
2672
+ try {
2673
+ const entries = fs
2674
+ .readdirSync(location, { withFileTypes: true })
2675
+ .map((entry) => {
2676
+ const kind = entry.isDirectory()
2677
+ ? "directory"
2678
+ : entry.isFile()
2679
+ ? "file"
2680
+ : entry.isSymbolicLink()
2681
+ ? "symlink"
2682
+ : "other";
2683
+ let target = "";
2684
+ if (entry.isSymbolicLink()) {
2685
+ try {
2686
+ target = fs.readlinkSync(path.join(location, entry.name));
2687
+ } catch {
2688
+ target = "<unreadable>";
2689
+ }
2221
2690
  }
2222
- }
2223
- return entry.name + "\0" + kind + "\0" + target;
2224
- })
2225
- .sort();
2226
- return crypto.createHash("sha256").update(entries.join("\0")).digest("hex");
2691
+ return entry.name + "\0" + kind + "\0" + target;
2692
+ })
2693
+ .sort();
2694
+ return crypto.createHash("sha256").update(entries.join("\0")).digest("hex");
2695
+ } catch {
2696
+ return "";
2697
+ }
2227
2698
  }
2228
2699
 
2229
2700
  function matchesProjectInputGlob(