ttsc 0.21.0 → 0.23.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 (119) hide show
  1. package/cmd/ttscserver/main.go +79 -3
  2. package/cmd/ttscserver/run_lsp_uses_native_plugin_manifest_source_test.go +96 -16
  3. package/driver/lsp.go +4 -0
  4. package/go.mod +1 -1
  5. package/internal/lspserver/lsp_document_diagnostics_omits_cached_project_when_current_result_has_none_test.go +48 -0
  6. package/internal/lspserver/lsp_executable_project_input_change_requests_launcher_restart_test.go +98 -0
  7. package/internal/lspserver/lsp_external_watched_change_retains_program_test.go +53 -0
  8. package/internal/lspserver/lsp_hints_refresh_scheduled_by_editor_events_test.go +9 -0
  9. package/internal/lspserver/lsp_launcher_reload_directory_fingerprint_parity_windows_test.go +94 -0
  10. package/internal/lspserver/lsp_native_plugin_source.go +171 -55
  11. package/internal/lspserver/lsp_native_resident.go +110 -9
  12. package/internal/lspserver/lsp_plugin.go +6 -0
  13. package/internal/lspserver/lsp_project_context_transport_identity_test.go +43 -0
  14. package/internal/lspserver/lsp_project_diagnostics.go +235 -0
  15. package/internal/lspserver/lsp_project_diagnostics_capability_is_independent_test.go +67 -0
  16. package/internal/lspserver/lsp_project_diagnostics_program_input_overlap_refreshes_all_producers_test.go +78 -0
  17. package/internal/lspserver/lsp_project_diagnostics_refresh_accumulates_owner_freshness_test.go +65 -0
  18. package/internal/lspserver/lsp_project_diagnostics_refresh_keeps_each_producer_last_good_test.go +90 -0
  19. package/internal/lspserver/lsp_project_diagnostics_refreshes_only_input_owners_test.go +62 -0
  20. package/internal/lspserver/lsp_project_diagnostics_resident_unsupported_falls_back_test.go +123 -0
  21. package/internal/lspserver/lsp_project_diagnostics_resume_preserves_generation_test.go +52 -0
  22. package/internal/lspserver/lsp_project_diagnostics_translates_relative_client_project_test.go +90 -0
  23. package/internal/lspserver/lsp_project_input_glob_path_other.go +16 -0
  24. package/internal/lspserver/lsp_project_input_glob_path_windows.go +36 -0
  25. package/internal/lspserver/lsp_project_input_ownership_resolves_declared_spellings_test.go +95 -0
  26. package/internal/lspserver/lsp_project_input_path_key_other.go +17 -0
  27. package/internal/lspserver/lsp_project_input_path_key_windows.go +228 -0
  28. package/internal/lspserver/lsp_project_input_path_key_windows_test.go +460 -0
  29. package/internal/lspserver/lsp_project_input_registration.go +504 -0
  30. package/internal/lspserver/lsp_project_input_registration_rechecks_selection_baseline_test.go +71 -0
  31. package/internal/lspserver/lsp_project_input_registration_reconciles_rejected_pending_request_test.go +268 -0
  32. package/internal/lspserver/lsp_project_input_registration_shapes_patterns_test.go +94 -0
  33. package/internal/lspserver/lsp_project_input_watchers_anchor_missing_paths_test.go +48 -0
  34. package/internal/lspserver/lsp_project_inputs.go +1012 -0
  35. package/internal/lspserver/lsp_project_inputs_match_missing_exact_and_glob_test.go +129 -0
  36. package/internal/lspserver/lsp_project_inputs_reject_invalid_and_foreign_snapshots_test.go +110 -0
  37. package/internal/lspserver/lsp_proxy.go +591 -28
  38. package/internal/lspserver/lsp_reload_directories_compare_immediate_topology_test.go +188 -0
  39. package/internal/lspserver/lsp_reload_directories_separate_glob_territory_test.go +181 -0
  40. package/internal/lspserver/lsp_reload_file_fingerprint_preserves_raw_symlink_target_test.go +47 -0
  41. package/internal/lspserver/lsp_resident_falls_back_when_serve_unsupported_test.go +1 -1
  42. package/internal/lspserver/lsp_resident_invalidation_broadcasts_program_input_overlap_test.go +60 -0
  43. package/internal/lspserver/lsp_resident_invalidation_targets_project_input_owners_test.go +51 -0
  44. package/internal/lspserver/lsp_same_transport_entries_are_deduplicated_test.go +66 -0
  45. package/lib/compiler/internal/project/readProjectConfig.d.ts +5 -3
  46. package/lib/compiler/internal/project/readProjectConfig.js +46 -14
  47. package/lib/compiler/internal/project/readProjectConfig.js.map +1 -1
  48. package/lib/compiler/internal/residentCheckProcess.d.ts +46 -0
  49. package/lib/compiler/internal/residentCheckProcess.js +227 -0
  50. package/lib/compiler/internal/residentCheckProcess.js.map +1 -0
  51. package/lib/compiler/internal/resolveEmittedJavaScript.d.ts +2 -1
  52. package/lib/compiler/internal/resolveEmittedJavaScript.js +35 -18
  53. package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
  54. package/lib/compiler/internal/runBuild.d.ts +64 -1
  55. package/lib/compiler/internal/runBuild.js +484 -7
  56. package/lib/compiler/internal/runBuild.js.map +1 -1
  57. package/lib/compiler/internal/runSingleFileEmit.js +1 -0
  58. package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
  59. package/lib/flags/parser.js +13 -17
  60. package/lib/flags/parser.js.map +1 -1
  61. package/lib/flags/schema.d.ts +10 -3
  62. package/lib/flags/schema.js +99 -0
  63. package/lib/flags/schema.js.map +1 -1
  64. package/lib/internal/projectInputPathIdentity.d.ts +31 -0
  65. package/lib/internal/projectInputPathIdentity.js +276 -0
  66. package/lib/internal/projectInputPathIdentity.js.map +1 -0
  67. package/lib/internal/windowsDirectoryCaseSensitivity.d.ts +10 -0
  68. package/lib/internal/windowsDirectoryCaseSensitivity.js +34 -0
  69. package/lib/internal/windowsDirectoryCaseSensitivity.js.map +1 -0
  70. package/lib/launcher/internal/runTtsc.d.ts +17 -0
  71. package/lib/launcher/internal/runTtsc.js +126 -88
  72. package/lib/launcher/internal/runTtsc.js.map +1 -1
  73. package/lib/launcher/internal/runTtscserver.d.ts +13 -1
  74. package/lib/launcher/internal/runTtscserver.js +468 -47
  75. package/lib/launcher/internal/runTtscserver.js.map +1 -1
  76. package/lib/launcher/internal/runTtsx.js +61 -7
  77. package/lib/launcher/internal/runTtsx.js.map +1 -1
  78. package/lib/launcher/internal/runtimeHooks.d.ts +6 -5
  79. package/lib/launcher/internal/runtimeHooks.js +7 -15
  80. package/lib/launcher/internal/runtimeHooks.js.map +1 -1
  81. package/lib/launcher/internal/singleFileOutput.d.ts +16 -0
  82. package/lib/launcher/internal/singleFileOutput.js +103 -0
  83. package/lib/launcher/internal/singleFileOutput.js.map +1 -0
  84. package/lib/launcher/internal/watchTopology.d.ts +288 -2
  85. package/lib/launcher/internal/watchTopology.js +1921 -54
  86. package/lib/launcher/internal/watchTopology.js.map +1 -1
  87. package/lib/plugin/internal/descriptorProcessFailure.d.ts +32 -0
  88. package/lib/plugin/internal/descriptorProcessFailure.js +73 -0
  89. package/lib/plugin/internal/descriptorProcessFailure.js.map +1 -0
  90. package/lib/plugin/internal/loadProjectPlugins.d.ts +1 -0
  91. package/lib/plugin/internal/loadProjectPlugins.js +34 -8
  92. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  93. package/lib/structures/ITtscPluginCapabilities.d.ts +31 -0
  94. package/lib/structures/internal/ITtscProjectInputSnapshot.d.ts +29 -0
  95. package/lib/structures/internal/ITtscProjectInputSnapshot.js +3 -0
  96. package/lib/structures/internal/ITtscProjectInputSnapshot.js.map +1 -0
  97. package/lib/structures/internal/TtscSingleFileEmitOptions.d.ts +5 -0
  98. package/package.json +12 -8
  99. package/src/compiler/internal/project/readProjectConfig.ts +62 -8
  100. package/src/compiler/internal/residentCheckProcess.ts +279 -0
  101. package/src/compiler/internal/resolveEmittedJavaScript.ts +43 -21
  102. package/src/compiler/internal/runBuild.ts +728 -12
  103. package/src/compiler/internal/runSingleFileEmit.ts +1 -0
  104. package/src/flags/parser.ts +17 -17
  105. package/src/flags/schema.ts +112 -3
  106. package/src/internal/projectInputPathIdentity.ts +377 -0
  107. package/src/internal/windowsDirectoryCaseSensitivity.ts +33 -0
  108. package/src/launcher/internal/runTtsc.ts +148 -117
  109. package/src/launcher/internal/runTtscserver.ts +633 -56
  110. package/src/launcher/internal/runTtsx.ts +87 -10
  111. package/src/launcher/internal/runtimeHooks.ts +16 -16
  112. package/src/launcher/internal/singleFileOutput.ts +133 -0
  113. package/src/launcher/internal/watchTopology.ts +2494 -74
  114. package/src/plugin/internal/descriptorProcessFailure.ts +100 -0
  115. package/src/plugin/internal/loadProjectPlugins.ts +49 -8
  116. package/src/structures/ITtscPluginCapabilities.ts +34 -0
  117. package/src/structures/internal/ITtscProjectInputSnapshot.ts +29 -0
  118. package/src/structures/internal/TtscSingleFileEmitOptions.ts +5 -0
  119. package/tools/shim_audit/baseline.json +0 -9
@@ -1,3 +1,4 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
 
@@ -5,12 +6,28 @@ import { readJsoncFile } from "../../compiler/internal/project/readConfigJson";
5
6
  import { readProjectConfig } from "../../compiler/internal/project/readProjectConfig";
6
7
  import { resolveTsgo } from "../../compiler/internal/resolveTsgo";
7
8
  import { outputText, spawnNative } from "../../compiler/internal/spawnNative";
9
+ import { resolveFlagSpec } from "../../flags/schema";
10
+ import {
11
+ type ProjectInputPathIdentity,
12
+ type ProjectInputPathIdentityContext,
13
+ createProjectInputPathIdentityContext,
14
+ isProjectInputPathIdentityWithin,
15
+ resolveProjectInputPath,
16
+ } from "../../internal/projectInputPathIdentity";
8
17
  import type { ITtscParsedProjectConfig } from "../../structures/internal/ITtscParsedProjectConfig";
18
+ import type { ITtscProjectInputSnapshot } from "../../structures/internal/ITtscProjectInputSnapshot";
9
19
  import type { TtscBuildOptions } from "../../structures/internal/TtscBuildOptions";
20
+ import { resolveSingleFileOutput } from "./singleFileOutput";
10
21
 
11
22
  type WatchTopologyOptions = Pick<
12
23
  TtscBuildOptions,
13
- "binary" | "env" | "outDir" | "passthrough" | "projectRoot" | "tsconfig"
24
+ | "binary"
25
+ | "emit"
26
+ | "env"
27
+ | "outDir"
28
+ | "passthrough"
29
+ | "projectRoot"
30
+ | "tsconfig"
14
31
  > & {
15
32
  cwd: string;
16
33
  files: readonly string[];
@@ -18,14 +35,42 @@ type WatchTopologyOptions = Pick<
18
35
 
19
36
  type WatchTopologyCallbacks = {
20
37
  onError(location: string, error: unknown): void;
21
- onInputChange(): void;
38
+ onInputChange(change: WatchInputChange): void;
39
+ onProjectInputWatchUnavailable?(roots: readonly string[]): void;
40
+ onProjectInputWatchRoots?(roots: readonly string[]): void;
22
41
  onTopologyChange(): void;
23
42
  };
24
43
 
44
+ export type WatchInputChange = {
45
+ /** Keep the resident process but cold-load its compiler Program. */
46
+ invalidate?: boolean;
47
+ kind: "compiler" | "config" | "plugin" | "project";
48
+ path?: string;
49
+ };
50
+
25
51
  type ResolvedWatchTopology = {
52
+ analysisOnly: boolean;
26
53
  directories: Map<string, string>;
27
54
  files: Map<string, string>;
55
+ outputFiles: Map<string, string>;
28
56
  outputs: Map<string, string>;
57
+ reloadFiles: Map<string, string>;
58
+ };
59
+
60
+ type CompilerFileSnapshot = {
61
+ content: string;
62
+ owner: string;
63
+ };
64
+
65
+ type CompilerFileMovement = {
66
+ content: boolean;
67
+ owner: boolean;
68
+ };
69
+
70
+ export type CompilerDirectoryWatchEventPlan = {
71
+ changes: string[];
72
+ rearm: string[];
73
+ refresh: boolean;
29
74
  };
30
75
 
31
76
  /**
@@ -37,14 +82,51 @@ type ResolvedWatchTopology = {
37
82
  * outputs are filtered before any watcher is installed.
38
83
  */
39
84
  export class WatchTopology {
85
+ private analysisOnly = false;
86
+ private closed = false;
87
+ private compilerPostRegistrationMembershipRefresh = false;
88
+ private compilerPostRegistrationReconciliationScheduled = false;
89
+ private compilerPostRegistrationSkipUnobservedProjectInputWatchRoots = true;
40
90
  private directories = new Map<string, string>();
41
91
  private directoryWatchers = new Map<string, fs.FSWatcher>();
42
92
  private extraInputs: readonly string[] = [];
43
93
  private extraWatchers = new Map<string, fs.FSWatcher>();
94
+ private compilerFileSnapshots = new Map<string, CompilerFileSnapshot>();
44
95
  private files = new Map<string, string>();
45
96
  private fileWatchers = new Map<string, fs.FSWatcher>();
46
97
  private observedDirectories = new Map<string, string>();
98
+ private outputFiles = new Map<string, string>();
47
99
  private outputs = new Map<string, string>();
100
+ private projectInputFingerprints = new Map<string, string>();
101
+ private projectInputMatches = new Map<string, string>();
102
+ private projectInputs: ITtscProjectInputSnapshot = {
103
+ files: [],
104
+ globs: [],
105
+ reloadDirectories: [],
106
+ reloadFiles: [],
107
+ root: "",
108
+ };
109
+ private declaredProjectInputs: ITtscProjectInputSnapshot = {
110
+ files: [],
111
+ globs: [],
112
+ reloadDirectories: [],
113
+ reloadFiles: [],
114
+ root: "",
115
+ };
116
+ private projectInputRecoveryScheduled = false;
117
+ private projectInputPostRegistrationReconciliationScheduled = false;
118
+ private projectInputRejectedWatchRoots = new Set<string>();
119
+ private projectInputRequiredWatchRoots = new Map<string, string>();
120
+ private projectInputUnobservedWatchRoots = new Map<string, string>();
121
+ private projectInputWatchRoots = new Map<string, string>();
122
+ private projectInputWatchers = new Map<string, fs.FSWatcher>();
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>();
129
+ private reloadFiles = new Map<string, string>();
48
130
 
49
131
  public constructor(
50
132
  private readonly options: WatchTopologyOptions,
@@ -53,20 +135,153 @@ export class WatchTopology {
53
135
 
54
136
  /** Re-resolve compiler inputs and notify only when their membership changed. */
55
137
  public refresh(notify: boolean): void {
138
+ this.refreshCompilerInputs(notify, false);
139
+ }
140
+
141
+ private refreshCompilerInputs(
142
+ notify: boolean,
143
+ skipUnobservedProjectInputWatchRoots: boolean,
144
+ ): void {
56
145
  const next = resolveWatchTopology(this.options, this.extraInputs);
146
+ const compilerProgramMembershipChange =
147
+ next.analysisOnly &&
148
+ mapsEqual(this.reloadFiles, next.reloadFiles) &&
149
+ mapsEqual(this.outputFiles, next.outputFiles) &&
150
+ mapsEqual(this.outputs, next.outputs)
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
162
+ : undefined;
57
163
  const changed =
164
+ this.analysisOnly !== next.analysisOnly ||
58
165
  mapsEqual(this.files, next.files) === false ||
59
166
  mapsEqual(this.directories, next.directories) === false ||
60
- mapsEqual(this.outputs, next.outputs) === false;
167
+ mapsEqual(this.outputFiles, next.outputFiles) === false ||
168
+ mapsEqual(this.outputs, next.outputs) === false ||
169
+ mapsEqual(this.reloadFiles, next.reloadFiles) === false;
170
+ this.analysisOnly = next.analysisOnly;
61
171
  this.files = next.files;
172
+ // Stamp the tracked set as it is resolved, so the first event that cannot
173
+ // name what changed compares against the state the compiler just saw rather
174
+ // than against nothing, which would make it nominate everything once.
175
+ for (const key of [...this.compilerFileSnapshots.keys()]) {
176
+ if (!next.files.has(key)) this.compilerFileSnapshots.delete(key);
177
+ }
178
+ for (const [key, file] of next.files) {
179
+ // Only a file with no stamp yet is seeded. Restamping one that already
180
+ // has a baseline would advance it past a change nobody reported, and the
181
+ // next unnamed event would then read that change as no change at all.
182
+ if (!this.compilerFileSnapshots.has(key)) {
183
+ this.compilerFileSnapshots.set(key, compilerFileSnapshot(file));
184
+ }
185
+ }
62
186
  this.directories = next.directories;
187
+ this.outputFiles = next.outputFiles;
63
188
  this.outputs = next.outputs;
64
- this.syncFileWatchers();
65
- this.syncDirectoryWatchers();
189
+ this.reloadFiles = next.reloadFiles;
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();
66
203
  this.syncExtraWatchers();
204
+ this.syncProjectInputWatchers(skipUnobservedProjectInputWatchRoots);
205
+ if (fileWatcherRegistered || directoryWatcherRegistered) {
206
+ this.scheduleCompilerPostRegistrationReconciliation(
207
+ directoryWatcherRegistered,
208
+ skipUnobservedProjectInputWatchRoots,
209
+ );
210
+ }
67
211
  if (notify && changed) {
68
- this.callbacks.onTopologyChange();
212
+ if (projectInputProgramChange !== undefined) {
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
+ );
226
+ } else {
227
+ this.callbacks.onTopologyChange();
228
+ }
229
+ }
230
+ }
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
+ }
69
283
  }
284
+ return reload;
70
285
  }
71
286
 
72
287
  /** Add Go plugin source trees discovered by the real build lane. */
@@ -77,62 +292,312 @@ export class WatchTopology {
77
292
  this.refresh(false);
78
293
  }
79
294
 
295
+ /**
296
+ * Reconcile project-rule dependencies, retaining absent files and empty glob
297
+ * populations as live topology.
298
+ */
299
+ public setProjectInputs(inputs: ITtscProjectInputSnapshot): void {
300
+ const next = normalizeProjectInputSnapshot(inputs);
301
+ // The declared spellings are recorded even when the normalized snapshot did
302
+ // not move, because a republication can carry a new alias for identities
303
+ // that already matched, and anchoring the spelling that was retired would
304
+ // leave the live one unwatched.
305
+ this.declaredProjectInputs =
306
+ inputs.declared === undefined
307
+ ? inputs
308
+ : { ...inputs.declared, root: inputs.root };
309
+ if (projectInputSnapshotsEqual(this.projectInputs, next)) {
310
+ this.syncProjectInputWatchers();
311
+ return;
312
+ }
313
+ this.projectInputs = next;
314
+ this.projectInputRejectedWatchRoots.clear();
315
+ this.pruneProjectInputWatchRoots([
316
+ next,
317
+ inputs,
318
+ { ...(inputs.declared ?? inputs), root: inputs.root },
319
+ ]);
320
+ this.projectInputMatches = this.collectProjectInputMatches();
321
+ this.projectInputFingerprints = fingerprintProjectInputMatches(
322
+ this.projectInputMatches,
323
+ );
324
+ this.syncProjectInputWatchers();
325
+ }
326
+
80
327
  /** Close every watcher so SIGINT/SIGTERM can drain the event loop. */
81
328
  public close(): void {
329
+ this.closed = true;
82
330
  closeWatchers(this.fileWatchers);
83
331
  closeWatchers(this.directoryWatchers);
84
332
  closeWatchers(this.extraWatchers);
333
+ closeWatchers(this.projectInputWatchers);
334
+ closeWatchers(this.projectInputLinkWatchers);
85
335
  }
86
336
 
87
- private syncFileWatchers(): void {
337
+ private syncFileWatchers(skipMissing = false): boolean {
338
+ const previous = new Map(this.fileWatchers);
339
+ const 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;
88
347
  syncWatchers(
89
348
  this.fileWatchers,
90
- this.files,
349
+ files,
91
350
  (location) =>
92
- fs.watch(location, { persistent: true }, () => {
93
- this.callbacks.onInputChange();
94
- }),
351
+ fs.watch(
352
+ watcherRegistrationPath(location),
353
+ { persistent: true },
354
+ () => {
355
+ // A per-file watcher fires on any filesystem attention its target
356
+ // receives, and it carries no filename to distinguish an edit from
357
+ // a touch. It answers the same question the unnamed directory event
358
+ // answers, so it answers it the same way: from the bytes.
359
+ const movement = this.compilerFileMovement(location);
360
+ if (movement.owner) this.rearmFileWatchers([location], true);
361
+ if (!movement.content) return;
362
+ this.callbacks.onInputChange({
363
+ kind: this.classifyCompilerInput(location),
364
+ path: location,
365
+ });
366
+ },
367
+ ),
95
368
  (location, error) => this.callbacks.onError(location, error),
369
+ () => this.closed === false,
96
370
  );
371
+ return [...this.fileWatchers].some(
372
+ ([key, watcher]) => previous.get(key) !== watcher,
373
+ );
374
+ }
375
+
376
+ /** Compare a tracked file's content and physical owner with its snapshot. */
377
+ private compilerFileMovement(location: string): CompilerFileMovement {
378
+ const key = pathKey(location);
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
+ };
97
386
  }
98
387
 
99
- private syncDirectoryWatchers(): void {
388
+ private syncDirectoryWatchers(): boolean {
389
+ const previous = new Map(this.directoryWatchers);
100
390
  const desired = new Map(this.directories);
101
391
  for (const [key, location] of this.observedDirectories) {
102
392
  if (
103
393
  isDirectory(location) === false ||
104
- this.isCompilerOutputDirectory(location)
394
+ this.isCompilerOutputDirectory(location) ||
395
+ this.isProjectInputDirectory(location)
105
396
  ) {
106
397
  this.observedDirectories.delete(key);
107
398
  continue;
108
399
  }
109
400
  desired.set(key, location);
110
401
  }
402
+ if (process.platform === "win32") {
403
+ for (const [key, location] of desired) {
404
+ if (
405
+ [...desired].some(
406
+ ([candidateKey, candidate]) =>
407
+ candidateKey !== key && isPathWithin(candidate, location),
408
+ )
409
+ ) {
410
+ desired.delete(key);
411
+ }
412
+ }
413
+ }
111
414
  syncWatchers(
112
415
  this.directoryWatchers,
113
416
  desired,
114
417
  (location) =>
115
- fs.watch(location, { persistent: true }, (_event, filename) => {
116
- const changed =
117
- filename === null
118
- ? undefined
119
- : path.resolve(location, filename.toString());
120
- // File watchers own ordinary source/config edits. Directory watchers
121
- // only reconcile membership, so an emit in an unrelated output folder
122
- // cannot schedule another build.
123
- if (
124
- changed !== undefined &&
125
- this.files.has(pathKey(changed)) &&
126
- fs.existsSync(changed)
127
- ) {
128
- return;
129
- }
130
- this.refreshFromDirectory(location, changed);
131
- }),
418
+ fs.watch(
419
+ watcherRegistrationPath(location),
420
+ {
421
+ persistent: true,
422
+ recursive: process.platform === "win32",
423
+ },
424
+ (event, filename) => {
425
+ const changed =
426
+ filename === null
427
+ ? undefined
428
+ : path.resolve(location, filename.toString());
429
+ const pluginInput = changed ?? location;
430
+ if (this.isPluginInput(pluginInput)) {
431
+ this.callbacks.onInputChange({
432
+ kind: "plugin",
433
+ path: pluginInput,
434
+ });
435
+ return;
436
+ }
437
+ const plan = planCompilerDirectoryWatchEvent({
438
+ changed,
439
+ event,
440
+ exists: fs.existsSync,
441
+ location,
442
+ platform: process.platform,
443
+ trackedFiles: this.files,
444
+ });
445
+ this.rearmFileWatchers(plan.rearm);
446
+ for (const file of this.compilerChangesToReport(
447
+ plan.changes,
448
+ changed,
449
+ event,
450
+ )) {
451
+ this.callbacks.onInputChange({
452
+ kind: this.classifyCompilerInput(file),
453
+ path: file,
454
+ });
455
+ }
456
+ if (plan.refresh) this.refreshFromDirectory(location, changed);
457
+ },
458
+ ),
132
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,
133
464
  );
134
465
  }
135
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
+
543
+ /**
544
+ * Narrow a plan's changes to the tracked files that actually moved.
545
+ *
546
+ * A backend that cannot name what changed forces the plan to nominate every
547
+ * tracked file under the watched directory, which is the only safe answer it
548
+ * can give from an event carrying no filename. macOS delivers such events for
549
+ * ordinary activity elsewhere in the project, so the compiler lane would wake
550
+ * for sources nobody touched. Only a content notification passes through: it
551
+ * is the one event that claims the bytes moved. A rename claims the directory
552
+ * entry was rewritten and an unnamed event claims nothing, so both are
553
+ * decided from the bytes, which is the question neither of them answered.
554
+ */
555
+ private compilerChangesToReport(
556
+ changes: readonly string[],
557
+ changed: string | undefined,
558
+ event: string,
559
+ ): string[] {
560
+ // A content notification is taken at its word: the backend is telling us
561
+ // these bytes changed, and second-guessing it would lose an edit that
562
+ // landed inside the clock's resolution. A rename says the directory entry
563
+ // was rewritten, which is a different claim — a file can be moved back, or
564
+ // replaced by an identical copy, without its content moving at all — and an
565
+ // event that cannot name anything makes no claim about content either.
566
+ // Those two are decided from the bytes, and the rearm they drive is
567
+ // unaffected, because rebinding is about the inode and not the content.
568
+ if (changed !== undefined && event !== "rename") {
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
+ });
579
+ }
580
+ return changes.filter((file) => this.compilerFileMovement(file).content);
581
+ }
582
+
583
+ private recordCompilerFileSnapshot(file: string): void {
584
+ this.compilerFileSnapshots.set(pathKey(file), compilerFileSnapshot(file));
585
+ }
586
+
587
+ private rearmFileWatchers(
588
+ files: readonly string[],
589
+ skipMissing = false,
590
+ ): void {
591
+ for (const file of files) {
592
+ const key = pathKey(file);
593
+ this.fileWatchers.get(key)?.close();
594
+ this.fileWatchers.delete(key);
595
+ }
596
+ if (this.syncFileWatchers(skipMissing)) {
597
+ this.scheduleCompilerPostRegistrationReconciliation(false, true);
598
+ }
599
+ }
600
+
136
601
  private syncExtraWatchers(): void {
137
602
  const directories = new Map<string, string>();
138
603
  for (const input of this.extraInputs) {
@@ -144,24 +609,673 @@ export class WatchTopology {
144
609
  this.extraWatchers,
145
610
  directories,
146
611
  (location) =>
147
- fs.watch(location, { persistent: true }, () => {
148
- this.callbacks.onInputChange();
149
- }),
612
+ fs.watch(
613
+ watcherRegistrationPath(location),
614
+ { persistent: true },
615
+ (_event, filename) => {
616
+ const changed =
617
+ filename === null
618
+ ? undefined
619
+ : path.resolve(location, filename.toString());
620
+ this.callbacks.onInputChange({
621
+ kind: "plugin",
622
+ path: changed ?? location,
623
+ });
624
+ },
625
+ ),
150
626
  (location, error) => this.callbacks.onError(location, error),
627
+ () => this.closed === false,
151
628
  );
152
629
  }
153
630
 
631
+ private syncProjectInputWatchers(
632
+ skipUnobservedProjectInputWatchRoots = false,
633
+ ): void {
634
+ if (this.closed) return;
635
+ const previous = new Map(this.projectInputWatchers);
636
+ const previousLinks = new Map(this.projectInputLinkWatchers);
637
+ const identities = createProjectInputPathIdentityContext();
638
+ const desired = new Map<string, string>();
639
+ const required = new Map<string, string>();
640
+ for (const file of this.projectInputDeclarations("file")) {
641
+ if (this.isProjectInputCompilerOutput(file, identities)) continue;
642
+ const location = this.projectInputWatchRoot(
643
+ "file",
644
+ file,
645
+ path.dirname(file),
646
+ );
647
+ this.retainProjectInputWatchRoot(
648
+ required,
649
+ desired,
650
+ identities,
651
+ location,
652
+ path.dirname(file),
653
+ skipUnobservedProjectInputWatchRoots,
654
+ );
655
+ }
656
+ for (const glob of this.projectInputDeclarations("glob")) {
657
+ const root = literalGlobRoot(glob);
658
+ if (this.isProjectInputCompilerOutputDirectory(root, identities)) {
659
+ continue;
660
+ }
661
+ const location = this.projectInputWatchRoot("glob", glob, root);
662
+ this.retainProjectInputWatchRoot(
663
+ required,
664
+ desired,
665
+ identities,
666
+ location,
667
+ root,
668
+ skipUnobservedProjectInputWatchRoots,
669
+ );
670
+ }
671
+ for (const file of this.projectInputDeclarations("reload")) {
672
+ if (this.isProjectInputCompilerOutput(file, identities)) continue;
673
+ const location = this.projectInputWatchRoot(
674
+ "reload",
675
+ file,
676
+ path.dirname(file),
677
+ );
678
+ this.retainProjectInputWatchRoot(
679
+ required,
680
+ desired,
681
+ identities,
682
+ location,
683
+ path.dirname(file),
684
+ skipUnobservedProjectInputWatchRoots,
685
+ );
686
+ }
687
+ for (const directory of this.projectInputDeclarations("reload-directory")) {
688
+ if (this.isProjectInputCompilerOutputDirectory(directory, identities)) {
689
+ continue;
690
+ }
691
+ const location = this.projectInputWatchRoot(
692
+ "reload-directory",
693
+ directory,
694
+ directory,
695
+ );
696
+ this.retainProjectInputWatchRoot(
697
+ required,
698
+ desired,
699
+ identities,
700
+ location,
701
+ directory,
702
+ skipUnobservedProjectInputWatchRoots,
703
+ );
704
+ }
705
+ const active = new Map<string, string>();
706
+ for (const location of projectInputActiveWatchDirectories(
707
+ desired.values(),
708
+ identities,
709
+ )) {
710
+ const identity = identities.resolve(location);
711
+ active.set(identity.key, identity.path);
712
+ }
713
+ this.projectInputRequiredWatchRoots = required;
714
+ syncWatchers(
715
+ this.projectInputWatchers,
716
+ active,
717
+ (location) =>
718
+ fs.watch(
719
+ watcherRegistrationPath(location),
720
+ { persistent: true, recursive: true },
721
+ (_event, filename) => {
722
+ const changed =
723
+ filename === null
724
+ ? undefined
725
+ : path.resolve(location, filename.toString());
726
+ this.refreshProjectInputs(location, changed);
727
+ },
728
+ ),
729
+ (location, error) => {
730
+ const key = identities.resolve(location).key;
731
+ const firstFailure = !this.projectInputRejectedWatchRoots.has(key);
732
+ this.projectInputRejectedWatchRoots.add(key);
733
+ this.callbacks.onError(location, error);
734
+ if (firstFailure && !this.closed) {
735
+ this.scheduleProjectInputWatcherRecovery();
736
+ }
737
+ },
738
+ () => this.closed === false,
739
+ );
740
+ if (this.closed) return;
741
+ if (!this.projectInputRecoveryScheduled) {
742
+ this.reportUnobservedProjectInputWatchRoots(required);
743
+ }
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
+ }
755
+ this.callbacks.onProjectInputWatchRoots?.(
756
+ [...this.projectInputWatchers.keys()]
757
+ .map((key) => active.get(key) ?? identities.resolve(key).path)
758
+ .sort(),
759
+ );
760
+ }
761
+
762
+ /**
763
+ * Watch the directory that holds a declaration which is itself a link.
764
+ *
765
+ * A recursive watcher cannot report the link being replaced. The backend that
766
+ * keys its handles by path skips an entry it already knows, and the handle it
767
+ * put on the entry followed the link to the target's inode, which unlinking
768
+ * and recreating the link never touches. A plain directory watch has neither
769
+ * property: it reports the entry by name the moment it moves. These are kept
770
+ * apart from the recursive roots because they are not roots — they observe
771
+ * one directory, they are never reported as watch roots, and an ancestor
772
+ * covering them does not make them redundant.
773
+ */
774
+ private syncProjectInputLinkWatchers(
775
+ identities: ProjectInputPathIdentityContext,
776
+ ): void {
777
+ const desired = new Map<string, string>();
778
+ for (const kind of ["file", "reload"] as const) {
779
+ for (const declaration of this.projectInputDeclarations(kind)) {
780
+ const declared = path.resolve(declaration);
781
+ // The test is whether the declaration is itself a link, not whether its
782
+ // spelling is canonical. Comparing against the resolved identity would
783
+ // admit every declaration whose ancestor is aliased — which on macOS is
784
+ // every declaration under the system temporary directory — and it would
785
+ // still miss the retarget, because the watcher goes below the link.
786
+ if (!isSymbolicLink(declared)) continue;
787
+ if (this.isProjectInputCompilerOutput(declared, identities)) continue;
788
+ const parent = nearestExistingDirectory(path.dirname(declared));
789
+ if (parent === undefined) continue;
790
+ desired.set(identities.resolve(parent).key, parent);
791
+ }
792
+ }
793
+ syncWatchers(
794
+ this.projectInputLinkWatchers,
795
+ desired,
796
+ (location) =>
797
+ fs.watch(
798
+ watcherRegistrationPath(location),
799
+ { persistent: true },
800
+ (_event, filename) => {
801
+ const changed =
802
+ filename === null
803
+ ? undefined
804
+ : path.resolve(location, filename.toString());
805
+ this.refreshProjectInputs(location, changed);
806
+ },
807
+ ),
808
+ (location, error) => this.callbacks.onError(location, error),
809
+ () => this.closed === false,
810
+ );
811
+ }
812
+
813
+ /** Drop the watcher that just reported a directory replacement. */
814
+ private retireProjectInputWatcher(
815
+ location: string,
816
+ identities: ProjectInputPathIdentityContext,
817
+ ): void {
818
+ const key = identities.resolve(location).key;
819
+ // A plain directory watcher binds an inode, so a replacement strands it
820
+ // exactly as it strands a recursive root. Both maps are keyed the same way,
821
+ // so both are retired together and the next sync reinstalls whichever the
822
+ // declarations still call for.
823
+ for (const watchers of [
824
+ this.projectInputWatchers,
825
+ this.projectInputLinkWatchers,
826
+ ]) {
827
+ const watcher = watchers.get(key);
828
+ if (watcher === undefined) continue;
829
+ watcher.close();
830
+ watchers.delete(key);
831
+ }
832
+ }
833
+
834
+ private retainProjectInputWatchRoot(
835
+ required: Map<string, string>,
836
+ desired: Map<string, string>,
837
+ identities: ProjectInputPathIdentityContext,
838
+ location: string | undefined,
839
+ target: string,
840
+ skipUnobservedProjectInputWatchRoots: boolean,
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
+ }
860
+ const available = projectInputAvailableWatchDirectory(
861
+ location,
862
+ this.projectInputRejectedWatchRoots,
863
+ identities,
864
+ this.projectInputs.root,
865
+ );
866
+ if (available === undefined) return;
867
+ const identity = identities.resolve(available);
868
+ desired.set(identity.key, identity.path);
869
+ }
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
+
995
+ /**
996
+ * One snapshot holding every spelling of every declaration.
997
+ *
998
+ * Consumers that decide from a population rather than from a single path have
999
+ * to see both, or half of them answer from the file a link pointed at when
1000
+ * the snapshot was published while the event they are judging resolved to the
1001
+ * file it points at now.
1002
+ */
1003
+ private projectInputPopulation(): ITtscProjectInputSnapshot {
1004
+ return {
1005
+ files: this.projectInputDeclarations("file"),
1006
+ globs: this.projectInputDeclarations("glob"),
1007
+ reloadDirectories: this.projectInputDeclarations("reload-directory"),
1008
+ reloadFiles: this.projectInputDeclarations("reload"),
1009
+ root: this.projectInputs.root,
1010
+ };
1011
+ }
1012
+
1013
+ /**
1014
+ * Every spelling of one declaration that has to be anchored separately.
1015
+ *
1016
+ * The retained snapshot is normalized to physical identities, which is what
1017
+ * every comparison needs but not what every watcher needs: a declaration
1018
+ * reached through a symlink resolves to its target's directory, so anchoring
1019
+ * the normalized form alone watches the bytes and never the link. Retargeting
1020
+ * or replacing the link then goes unobserved, even though it is exactly what
1021
+ * decides which bytes the declaration names next. Both spellings are planned
1022
+ * through the same root selection, so the project-root hoist and the
1023
+ * nearest-existing-ancestor boundary still bound each of them, and the active
1024
+ * set drops one again whenever they coincide or share an ancestor.
1025
+ */
1026
+ private projectInputDeclarations(
1027
+ kind: "file" | "glob" | "reload" | "reload-directory",
1028
+ ): string[] {
1029
+ const select = (snapshot: ITtscProjectInputSnapshot): readonly string[] =>
1030
+ kind === "file"
1031
+ ? snapshot.files
1032
+ : kind === "glob"
1033
+ ? snapshot.globs
1034
+ : kind === "reload"
1035
+ ? (snapshot.reloadFiles ?? [])
1036
+ : (snapshot.reloadDirectories ?? []);
1037
+ const seen = new Set<string>();
1038
+ const declarations: string[] = [];
1039
+ for (const entry of [
1040
+ ...select(this.projectInputs),
1041
+ ...select(this.declaredProjectInputs),
1042
+ ]) {
1043
+ const key = resolveProjectInputPath(entry);
1044
+ if (seen.has(key)) continue;
1045
+ seen.add(key);
1046
+ declarations.push(entry);
1047
+ }
1048
+ return declarations;
1049
+ }
1050
+
1051
+ private projectInputWatchRoot(
1052
+ kind: "file" | "glob" | "reload" | "reload-directory",
1053
+ declaration: string,
1054
+ target: string,
1055
+ ): string | undefined {
1056
+ const key = projectInputDeclarationKey(kind, declaration);
1057
+ const retained = this.projectInputWatchRoots.get(key);
1058
+ if (retained !== undefined && isDirectory(retained)) return retained;
1059
+ const resolved = projectInputRecursiveWatchRoot(
1060
+ target,
1061
+ this.projectInputs.root,
1062
+ );
1063
+ if (resolved !== undefined) this.projectInputWatchRoots.set(key, resolved);
1064
+ return resolved;
1065
+ }
1066
+
1067
+ private refreshProjectInputs(
1068
+ location: string,
1069
+ changed?: string,
1070
+ skipUnobservedProjectInputWatchRoots = false,
1071
+ ): void {
1072
+ try {
1073
+ const previous = this.projectInputMatches;
1074
+ const identities = createProjectInputPathIdentityContext();
1075
+ // One population for the whole decision. Every question below is asked of
1076
+ // the same declarations, and rebuilding it per question would both cost
1077
+ // more and let two answers disagree about what was declared.
1078
+ const population = this.projectInputPopulation();
1079
+ const directlyMatched =
1080
+ changed !== undefined &&
1081
+ (previous.has(identities.resolve(changed).key) ||
1082
+ matchesProjectInput(population, changed, identities));
1083
+ const topologyMatched =
1084
+ changed !== undefined &&
1085
+ projectInputTopologyMayAffect(
1086
+ population,
1087
+ changed,
1088
+ previous,
1089
+ identities,
1090
+ );
1091
+ if (
1092
+ changed !== undefined &&
1093
+ (this.isProjectInputCompilerOutput(changed, identities) ||
1094
+ (directlyMatched === false && topologyMatched === false))
1095
+ ) {
1096
+ return;
1097
+ }
1098
+ // Rearm before snapshotting. A watcher that has to be replaced stops
1099
+ // delivering the moment it is closed, so a scan taken first would become
1100
+ // the baseline for a window in which nothing was watched, and anything
1101
+ // written there would never be announced again. Reinstalling first makes
1102
+ // the scan below observe whatever the gap swallowed.
1103
+ if (
1104
+ changed !== undefined &&
1105
+ projectInputReplacementStrandsWatchers(population, changed, identities)
1106
+ ) {
1107
+ this.retireProjectInputWatcher(location, identities);
1108
+ this.syncProjectInputWatchers();
1109
+ }
1110
+ const next = this.collectProjectInputMatches();
1111
+ const membershipChanged = mapsEqual(previous, next) === false;
1112
+ const nextFingerprints =
1113
+ changed === undefined ||
1114
+ membershipChanged ||
1115
+ directlyMatched ||
1116
+ topologyMatched
1117
+ ? fingerprintProjectInputMatches(next)
1118
+ : this.projectInputFingerprints;
1119
+ const contentChanged =
1120
+ mapsEqual(this.projectInputFingerprints, nextFingerprints) === false;
1121
+ const changedInputs = projectInputChangedPaths({
1122
+ next,
1123
+ nextFingerprints,
1124
+ previous,
1125
+ previousFingerprints: this.projectInputFingerprints,
1126
+ });
1127
+ const reconciledChange =
1128
+ changed ?? (changedInputs.length === 1 ? changedInputs[0] : undefined);
1129
+ // Both spellings classify the event. The normalized form names the file a
1130
+ // link pointed at when the snapshot was published, so after a retarget it
1131
+ // names the wrong one; only the declared form resolves to what the link
1132
+ // points at now, which is the selection this lane exists to protect.
1133
+ const reload = projectInputReloadEventShouldNotify({
1134
+ changed: reconciledChange,
1135
+ changedInputs,
1136
+ globs: population.globs,
1137
+ reloadDirectories: population.reloadDirectories ?? [],
1138
+ reloadFiles: population.reloadFiles ?? [],
1139
+ });
1140
+ const invalidate = projectInputMembershipInvalidatesProgram({
1141
+ changed: reconciledChange,
1142
+ changedInputs,
1143
+ contentChanged,
1144
+ next,
1145
+ previous,
1146
+ });
1147
+ this.projectInputMatches = next;
1148
+ this.projectInputFingerprints = nextFingerprints;
1149
+ this.syncProjectInputWatchers(skipUnobservedProjectInputWatchRoots);
1150
+ // A JSON/TS/JS project-input member can simultaneously enter or leave
1151
+ // the compiler Program. Reconcile the compiler watch snapshot before
1152
+ // scheduling its resident invalidation, so runWatch's post-cycle refresh
1153
+ // does not rediscover the same delta as a broader execution reload.
1154
+ if (invalidate) {
1155
+ this.refreshCompilerInputs(false, skipUnobservedProjectInputWatchRoots);
1156
+ }
1157
+ if (
1158
+ projectInputEventShouldNotify({
1159
+ contentChanged,
1160
+ directlyMatched,
1161
+ membershipChanged,
1162
+ }) &&
1163
+ (reconciledChange === undefined ||
1164
+ this.isProjectInputCompilerOutput(reconciledChange, identities) ===
1165
+ false)
1166
+ ) {
1167
+ this.callbacks.onInputChange(
1168
+ reload
1169
+ ? { kind: "config", path: reconciledChange }
1170
+ : {
1171
+ ...(invalidate ? { invalidate: true } : {}),
1172
+ kind: "project",
1173
+ path: reconciledChange,
1174
+ },
1175
+ );
1176
+ }
1177
+ } catch (error) {
1178
+ // A rename can invalidate the old filesystem object before the
1179
+ // replacement is readable. Rebind ancestor ownership even when the
1180
+ // population scan races that transient gap, so a later create cannot be
1181
+ // stranded without a watcher.
1182
+ this.syncProjectInputWatchers(skipUnobservedProjectInputWatchRoots);
1183
+ this.callbacks.onError(location, error);
1184
+ }
1185
+ }
1186
+
1187
+ private collectProjectInputMatches(): Map<string, string> {
1188
+ const identities = createProjectInputPathIdentityContext();
1189
+ const matches = new Map<string, string>();
1190
+ // Both spellings are scanned, and each is resolved here rather than when
1191
+ // the snapshot arrived. A declaration reached through a symlink otherwise
1192
+ // keeps the identity it had when it was published, so retargeting the link
1193
+ // moves no key, changes no fingerprint, and the cycle never learns that the
1194
+ // bytes it depends on are now a different file.
1195
+ for (const file of this.projectInputDeclarations("file")) {
1196
+ if (
1197
+ fs.existsSync(file) &&
1198
+ this.isProjectInputCompilerOutput(file, identities) === false
1199
+ ) {
1200
+ const identity = identities.resolve(file);
1201
+ matches.set(identity.key, identity.path);
1202
+ }
1203
+ }
1204
+ for (const file of this.projectInputDeclarations("reload")) {
1205
+ if (
1206
+ fs.existsSync(file) &&
1207
+ this.isProjectInputCompilerOutput(file, identities) === false
1208
+ ) {
1209
+ const identity = identities.resolve(file);
1210
+ matches.set(identity.key, identity.path);
1211
+ }
1212
+ }
1213
+ for (const directory of this.projectInputDeclarations("reload-directory")) {
1214
+ if (
1215
+ isDirectory(directory) &&
1216
+ this.isProjectInputCompilerOutputDirectory(directory, identities) ===
1217
+ false
1218
+ ) {
1219
+ const identity = identities.resolve(directory);
1220
+ matches.set(identity.key, identity.path);
1221
+ }
1222
+ }
1223
+ for (const glob of this.projectInputDeclarations("glob")) {
1224
+ const root = literalGlobRoot(glob);
1225
+ if (
1226
+ isDirectory(root) === false ||
1227
+ this.isProjectInputCompilerOutputDirectory(root, identities)
1228
+ ) {
1229
+ continue;
1230
+ }
1231
+ const stack = [root];
1232
+ while (stack.length !== 0) {
1233
+ const current = stack.pop()!;
1234
+ let entries: fs.Dirent[];
1235
+ try {
1236
+ entries = fs.readdirSync(current, { withFileTypes: true });
1237
+ } catch (error) {
1238
+ if (isVanishedFilesystemEntry(error)) continue;
1239
+ throw error;
1240
+ }
1241
+ for (const entry of entries) {
1242
+ const location = path.join(current, entry.name);
1243
+ if (this.isProjectInputCompilerOutput(location, identities)) {
1244
+ continue;
1245
+ }
1246
+ if (entry.isDirectory()) {
1247
+ stack.push(location);
1248
+ } else if (
1249
+ entry.isFile() &&
1250
+ matchesProjectInputGlob(glob, location, identities)
1251
+ ) {
1252
+ const identity = identities.resolve(location);
1253
+ matches.set(identity.key, identity.path);
1254
+ }
1255
+ }
1256
+ }
1257
+ }
1258
+ return matches;
1259
+ }
1260
+
154
1261
  private refreshFromDirectory(location: string, changed?: string): void {
155
1262
  if (
156
1263
  changed !== undefined &&
157
1264
  isDirectory(changed) &&
158
- this.isCompilerOutputDirectory(changed) === false
1265
+ this.isCompilerOutputDirectory(changed) === false &&
1266
+ this.isProjectInputDirectory(changed) === false
159
1267
  ) {
160
1268
  this.observedDirectories.set(pathKey(changed), changed);
161
1269
  }
162
1270
  try {
163
1271
  this.refresh(true);
164
1272
  } catch (error) {
1273
+ for (const reload of reloadInputsForFailedTopologyRefresh(
1274
+ this.reloadFiles.values(),
1275
+ changed,
1276
+ )) {
1277
+ this.callbacks.onInputChange({ kind: "config", path: reload });
1278
+ }
165
1279
  this.callbacks.onError(location, error);
166
1280
  }
167
1281
  }
@@ -171,14 +1285,131 @@ export class WatchTopology {
171
1285
  isPathWithin(output, location),
172
1286
  );
173
1287
  }
1288
+
1289
+ private isCompilerOutput(location: string): boolean {
1290
+ return (
1291
+ this.outputFiles.has(pathKey(location)) ||
1292
+ this.isCompilerOutputDirectory(location)
1293
+ );
1294
+ }
1295
+
1296
+ private isProjectInputCompilerOutputDirectory(
1297
+ location: string,
1298
+ identities: ProjectInputPathIdentityContext,
1299
+ ): boolean {
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
+ }
1306
+ return [...this.outputs.values()].some((output) => {
1307
+ if (!identities.isWithin(output, location)) return false;
1308
+ // An output directory that is the project itself, or holds it, is not a
1309
+ // place where only build products live -- the sources are there too. A
1310
+ // project emitting in place declares exactly that, and honouring it
1311
+ // literally would classify every declared input as a product and leave
1312
+ // the whole lane unwatched without anything failing to say so. Watching
1313
+ // is the safe side here: a product that gets watched costs an extra
1314
+ // rebuild, while an input that does not is never seen again.
1315
+ if (root !== "" && identities.isWithin(output, root)) return false;
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;
1325
+ });
1326
+ }
1327
+
1328
+ private isProjectInputCompilerOutput(
1329
+ location: string,
1330
+ identities: ProjectInputPathIdentityContext,
1331
+ ): boolean {
1332
+ const key = identities.resolve(location).key;
1333
+ return (
1334
+ [...this.outputFiles.values()].some(
1335
+ (output) => identities.resolve(output).key === key,
1336
+ ) || this.isProjectInputCompilerOutputDirectory(location, identities)
1337
+ );
1338
+ }
1339
+
1340
+ private isProjectInputDirectory(location: string): boolean {
1341
+ const resolved = path.resolve(location);
1342
+ const identities = createProjectInputPathIdentityContext();
1343
+ return (
1344
+ this.projectInputs.files.some(
1345
+ (file) =>
1346
+ identities.isWithin(resolved, file) ||
1347
+ identities.isWithin(path.dirname(file), resolved),
1348
+ ) ||
1349
+ (this.projectInputs.reloadFiles ?? []).some(
1350
+ (file) =>
1351
+ identities.isWithin(resolved, file) ||
1352
+ identities.isWithin(path.dirname(file), resolved),
1353
+ ) ||
1354
+ (this.projectInputs.reloadDirectories ?? []).some(
1355
+ (directory) =>
1356
+ identities.isWithin(resolved, directory) ||
1357
+ identities.isWithin(directory, resolved),
1358
+ ) ||
1359
+ this.projectInputs.globs.some((glob) => {
1360
+ const root = literalGlobRoot(glob);
1361
+ return (
1362
+ identities.isWithin(root, resolved) ||
1363
+ identities.isWithin(resolved, root)
1364
+ );
1365
+ })
1366
+ );
1367
+ }
1368
+
1369
+ private classifyCompilerInput(
1370
+ location: string,
1371
+ ): "compiler" | "config" | "plugin" {
1372
+ if (this.isPluginInput(location)) return "plugin";
1373
+ return this.reloadFiles.has(pathKey(location)) ? "config" : "compiler";
1374
+ }
1375
+
1376
+ private isPluginInput(location: string): boolean {
1377
+ const resolved = path.resolve(location);
1378
+ return this.extraInputs.some(
1379
+ (input) =>
1380
+ pathKey(input) === pathKey(resolved) || isPathWithin(input, resolved),
1381
+ );
1382
+ }
1383
+ }
1384
+
1385
+ export function reloadInputsForFailedTopologyRefresh(
1386
+ reloadFiles: Iterable<string>,
1387
+ changed?: string,
1388
+ ): string[] {
1389
+ const changedKey = changed === undefined ? undefined : pathKey(changed);
1390
+ const reloads = new Map<string, string>();
1391
+ for (const location of reloadFiles) {
1392
+ const resolved = path.resolve(location);
1393
+ const key = pathKey(resolved);
1394
+ if (
1395
+ (changedKey !== undefined && key === changedKey) ||
1396
+ fs.existsSync(resolved) === false
1397
+ ) {
1398
+ reloads.set(key, resolved);
1399
+ }
1400
+ }
1401
+ return [...reloads.values()].sort();
174
1402
  }
175
1403
 
176
1404
  function resolveWatchTopology(
177
1405
  options: WatchTopologyOptions,
178
1406
  extraInputs: readonly string[],
179
1407
  ): ResolvedWatchTopology {
1408
+ let analysisOnly = options.emit === false;
180
1409
  const files = new Map<string, string>();
1410
+ const outputFiles = new Map<string, string>();
181
1411
  const outputs = new Map<string, string>();
1412
+ const reloadFiles = new Map<string, string>();
182
1413
  const roots: string[] = [];
183
1414
  if (options.files.length !== 0) {
184
1415
  const project = readProjectConfig({
@@ -186,29 +1417,70 @@ function resolveWatchTopology(
186
1417
  projectRoot: options.projectRoot,
187
1418
  tsconfig: options.tsconfig,
188
1419
  });
1420
+ analysisOnly = watchTopologyAnalysisOnly(options, project);
189
1421
  roots.push(project.root);
190
1422
  addPaths(files, project.configPaths);
191
- addPaths(outputs, resolveCompilerOutputs(project, options).directories);
192
- addPaths(
193
- files,
194
- options.files.map((file) => path.resolve(options.cwd, file)),
1423
+ addPaths(reloadFiles, project.configPaths);
1424
+ const positionalInputs = options.files.map((file) =>
1425
+ path.resolve(options.cwd, file),
195
1426
  );
1427
+ if (
1428
+ positionalInputs.length === 1 &&
1429
+ (options.emit ?? project.compilerOptions.noEmit !== true)
1430
+ ) {
1431
+ addPaths(outputFiles, [
1432
+ resolveSingleFileOutput({
1433
+ cliOutDir: options.outDir,
1434
+ cwd: options.cwd,
1435
+ file: positionalInputs[0]!,
1436
+ passthrough: options.passthrough,
1437
+ tsconfig: options.tsconfig,
1438
+ }),
1439
+ ]);
1440
+ }
1441
+ addPaths(files, positionalInputs);
196
1442
  } else {
197
- for (const project of readReferencedProjects(options)) {
1443
+ const projects = readReferencedProjects(options);
1444
+ if (projects[0] !== undefined) {
1445
+ analysisOnly = watchTopologyAnalysisOnly(options, projects[0]);
1446
+ }
1447
+ for (const project of projects) {
198
1448
  roots.push(project.root);
199
1449
  addPaths(files, project.configPaths);
200
- addPaths(outputs, resolveCompilerOutputs(project, options).directories);
201
- addPaths(files, listCompilerInputs(project, options));
1450
+ addPaths(reloadFiles, project.configPaths);
1451
+ const compilerInputs = listCompilerInputs(project, options);
1452
+ const compilerOutputs = resolveCompilerOutputs(project, options);
1453
+ addPaths(outputFiles, compilerOutputs.files);
1454
+ addPaths(
1455
+ outputFiles,
1456
+ inferPerSourceCompilerOutputs(project, options, compilerInputs),
1457
+ );
1458
+ addPaths(outputs, compilerOutputs.directories);
1459
+ addPaths(files, compilerInputs);
202
1460
  }
203
1461
  }
204
1462
  addPaths(files, extraInputs);
205
1463
  return {
1464
+ analysisOnly,
206
1465
  directories: collectTopologyDirectories(files.values(), roots),
207
1466
  files,
1467
+ outputFiles,
208
1468
  outputs,
1469
+ reloadFiles,
209
1470
  };
210
1471
  }
211
1472
 
1473
+ function watchTopologyAnalysisOnly(
1474
+ options: WatchTopologyOptions,
1475
+ project: ITtscParsedProjectConfig,
1476
+ ): boolean {
1477
+ if (options.emit !== undefined) return options.emit === false;
1478
+ const noEmit =
1479
+ passthroughBooleanOption(options.passthrough, "--noEmit") ??
1480
+ project.compilerOptions.noEmit === true;
1481
+ return noEmit;
1482
+ }
1483
+
212
1484
  function readReferencedProjects(
213
1485
  options: WatchTopologyOptions,
214
1486
  ): ITtscParsedProjectConfig[] {
@@ -290,13 +1562,16 @@ function listCompilerInputs(
290
1562
  `ttsc: failed to list compiler inputs: ${result.error.message}`,
291
1563
  );
292
1564
  }
293
- 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.
294
1570
  const inputs = outputText(result.stdout)
295
1571
  .split(/\r?\n/)
296
1572
  .map((line) => line.trim())
297
1573
  .filter((line) => path.isAbsolute(line))
298
- .map((line) => path.resolve(line))
299
- .filter((file) => isCompilerOutput(file, outputs) === false);
1574
+ .map((line) => path.resolve(line));
300
1575
  if (result.status !== 0 && inputs.length === 0) {
301
1576
  throw new Error(
302
1577
  `ttsc: failed to list compiler inputs:\n${outputText(result.stderr) || outputText(result.stdout)}`,
@@ -309,28 +1584,311 @@ function resolveCompilerOutputs(
309
1584
  project: ITtscParsedProjectConfig,
310
1585
  options: WatchTopologyOptions,
311
1586
  ): { directories: string[]; files: string[] } {
312
- const compilerOptions = project.compilerOptions;
313
- const outDir = options.outDir
314
- ? path.resolve(options.cwd, options.outDir)
315
- : compilerOptions.outDir;
1587
+ const emit = effectiveCompilerEmit(project, options);
1588
+ const directories = new Set<string>();
1589
+ const files = new Set<string>();
1590
+ if (
1591
+ emit.outDir !== undefined &&
1592
+ (emit.javascript || (emit.declaration && emit.declarationDir === undefined))
1593
+ ) {
1594
+ directories.add(emit.outDir);
1595
+ }
1596
+ if (emit.declaration && emit.declarationDir !== undefined) {
1597
+ directories.add(emit.declarationDir);
1598
+ }
1599
+ if (emit.incremental) {
1600
+ files.add(defaultTsBuildInfoFile(project, emit));
1601
+ }
316
1602
  return {
317
- directories: [outDir, compilerOptions.declarationDir]
318
- .filter((value): value is string => typeof value === "string")
319
- .map((value) => path.resolve(value)),
320
- files: [compilerOptions.outFile, compilerOptions.tsBuildInfoFile]
321
- .filter((value): value is string => typeof value === "string")
322
- .map((value) => path.resolve(project.root, value)),
1603
+ directories: [...directories],
1604
+ files: [...files],
323
1605
  };
324
1606
  }
325
1607
 
326
- function isCompilerOutput(
327
- file: string,
328
- outputs: { directories: readonly string[]; files: readonly string[] },
329
- ): boolean {
330
- return (
331
- outputs.files.some((output) => pathKey(output) === pathKey(file)) ||
332
- outputs.directories.some((directory) => isPathWithin(directory, file))
1608
+ function inferPerSourceCompilerOutputs(
1609
+ project: ITtscParsedProjectConfig,
1610
+ options: WatchTopologyOptions,
1611
+ inputs: readonly string[],
1612
+ ): string[] {
1613
+ const emit = effectiveCompilerEmit(project, options);
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;
1620
+ for (const input of inputs) {
1621
+ const extension = path.extname(input).toLowerCase();
1622
+ if (/\.d\.(?:ts|mts|cts)$/i.test(input)) continue;
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;
1646
+ if (
1647
+ emit.javascript &&
1648
+ (emit.outDir !== undefined ||
1649
+ !isJavaScriptSourceExtension(extension) ||
1650
+ (extension === ".jsx" && emit.jsx !== "preserve"))
1651
+ ) {
1652
+ const javascriptExtension =
1653
+ extension === ".mts" || extension === ".mjs"
1654
+ ? ".mjs"
1655
+ : extension === ".cts" || extension === ".cjs"
1656
+ ? ".cjs"
1657
+ : (extension === ".tsx" || extension === ".jsx") &&
1658
+ emit.jsx === "preserve"
1659
+ ? ".jsx"
1660
+ : ".js";
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
+ }
1667
+ }
1668
+ if (emit.declaration) {
1669
+ const declarationExtension =
1670
+ extension === ".mts" || extension === ".mjs"
1671
+ ? ".d.mts"
1672
+ : extension === ".cts" || extension === ".cjs"
1673
+ ? ".d.cts"
1674
+ : ".d.ts";
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
+ }
1684
+ }
1685
+ }
1686
+ return [...outputs];
1687
+ }
1688
+
1689
+ function isCompilerEmittableSourceExtension(extension: string): boolean {
1690
+ return [
1691
+ ".cjs",
1692
+ ".cts",
1693
+ ".js",
1694
+ ".jsx",
1695
+ ".mjs",
1696
+ ".mts",
1697
+ ".ts",
1698
+ ".tsx",
1699
+ ].includes(extension);
1700
+ }
1701
+
1702
+ function isJavaScriptSourceExtension(extension: string): boolean {
1703
+ return [".cjs", ".js", ".jsx", ".mjs"].includes(extension);
1704
+ }
1705
+
1706
+ type EffectiveCompilerEmit = {
1707
+ declaration: boolean;
1708
+ declarationDir?: string;
1709
+ declarationMap: boolean;
1710
+ incremental: boolean;
1711
+ javascript: boolean;
1712
+ jsx?: unknown;
1713
+ outDir?: string;
1714
+ resolveJsonModule: boolean;
1715
+ rootDir?: string;
1716
+ sourceMap: boolean;
1717
+ tsBuildInfoFile?: string;
1718
+ };
1719
+
1720
+ function effectiveCompilerEmit(
1721
+ project: ITtscParsedProjectConfig,
1722
+ options: WatchTopologyOptions,
1723
+ ): EffectiveCompilerEmit {
1724
+ const compilerOptions = project.compilerOptions;
1725
+ const passthrough = options.passthrough;
1726
+ const noEmit =
1727
+ passthroughBooleanOption(passthrough, "--noEmit") ??
1728
+ (options.emit === false
1729
+ ? true
1730
+ : options.emit === true
1731
+ ? false
1732
+ : compilerOptions.noEmit === true);
1733
+ const composite =
1734
+ passthroughBooleanOption(passthrough, "--composite") ??
1735
+ compilerOptions.composite === true;
1736
+ const incremental =
1737
+ composite ||
1738
+ (passthroughBooleanOption(passthrough, "--incremental") ??
1739
+ compilerOptions.incremental === true);
1740
+ const emitDeclarationOnly =
1741
+ passthroughBooleanOption(passthrough, "--emitDeclarationOnly") ??
1742
+ (options.emit === true
1743
+ ? false
1744
+ : compilerOptions.emitDeclarationOnly === true);
1745
+ const declaration =
1746
+ !noEmit &&
1747
+ (composite ||
1748
+ (passthroughBooleanOption(passthrough, "--declaration") ??
1749
+ compilerOptions.declaration === true));
1750
+ const javascript = !noEmit && !emitDeclarationOnly;
1751
+ const sourceMap =
1752
+ javascript &&
1753
+ (passthroughBooleanOption(passthrough, "--sourceMap") ??
1754
+ compilerOptions.sourceMap === true) &&
1755
+ !(
1756
+ passthroughBooleanOption(passthrough, "--inlineSourceMap") ??
1757
+ compilerOptions.inlineSourceMap === true
1758
+ );
1759
+ const declarationMap =
1760
+ declaration &&
1761
+ (passthroughBooleanOption(passthrough, "--declarationMap") ??
1762
+ compilerOptions.declarationMap === true);
1763
+ const cliOutDir = passthroughPathOption(passthrough, "--outDir");
1764
+ const cliDeclarationDir = passthroughPathOption(
1765
+ passthrough,
1766
+ "--declarationDir",
333
1767
  );
1768
+ const cliRootDir = passthroughPathOption(passthrough, "--rootDir");
1769
+ const cliTsBuildInfoFile = passthroughPathOption(
1770
+ passthrough,
1771
+ "--tsBuildInfoFile",
1772
+ );
1773
+ const jsx =
1774
+ passthroughStringOption(passthrough, "--jsx") ?? compilerOptions.jsx;
1775
+ const compilerCwd = project.root;
1776
+ return {
1777
+ declaration,
1778
+ declarationDir:
1779
+ cliDeclarationDir === null
1780
+ ? undefined
1781
+ : cliDeclarationDir !== undefined
1782
+ ? path.resolve(compilerCwd, cliDeclarationDir)
1783
+ : typeof compilerOptions.declarationDir === "string"
1784
+ ? path.resolve(compilerOptions.declarationDir)
1785
+ : undefined,
1786
+ declarationMap,
1787
+ incremental,
1788
+ javascript,
1789
+ outDir:
1790
+ cliOutDir === null
1791
+ ? undefined
1792
+ : cliOutDir !== undefined
1793
+ ? path.resolve(compilerCwd, cliOutDir)
1794
+ : options.outDir !== undefined
1795
+ ? path.resolve(options.cwd, options.outDir)
1796
+ : typeof compilerOptions.outDir === "string"
1797
+ ? path.resolve(compilerOptions.outDir)
1798
+ : undefined,
1799
+ resolveJsonModule:
1800
+ passthroughBooleanOption(passthrough, "--resolveJsonModule") ??
1801
+ compilerOptions.resolveJsonModule === true,
1802
+ rootDir:
1803
+ cliRootDir === null
1804
+ ? undefined
1805
+ : cliRootDir !== undefined
1806
+ ? path.resolve(compilerCwd, cliRootDir)
1807
+ : typeof compilerOptions.rootDir === "string"
1808
+ ? path.resolve(compilerOptions.rootDir)
1809
+ : undefined,
1810
+ sourceMap,
1811
+ tsBuildInfoFile:
1812
+ cliTsBuildInfoFile === null
1813
+ ? undefined
1814
+ : cliTsBuildInfoFile !== undefined
1815
+ ? path.resolve(compilerCwd, cliTsBuildInfoFile)
1816
+ : typeof compilerOptions.tsBuildInfoFile === "string"
1817
+ ? path.resolve(compilerOptions.tsBuildInfoFile)
1818
+ : undefined,
1819
+ jsx,
1820
+ };
1821
+ }
1822
+
1823
+ function defaultTsBuildInfoFile(
1824
+ project: ITtscParsedProjectConfig,
1825
+ emit: EffectiveCompilerEmit,
1826
+ ): string {
1827
+ if (emit.tsBuildInfoFile !== undefined) return emit.tsBuildInfoFile;
1828
+ const configWithoutExtension = replaceOutputExtension(project.path, "");
1829
+ if (emit.outDir === undefined) return `${configWithoutExtension}.tsbuildinfo`;
1830
+ const relative =
1831
+ emit.rootDir === undefined
1832
+ ? path.basename(configWithoutExtension)
1833
+ : path.relative(emit.rootDir, configWithoutExtension);
1834
+ return path.resolve(emit.outDir, `${relative}.tsbuildinfo`);
1835
+ }
1836
+
1837
+ function replaceOutputExtension(location: string, extension: string): string {
1838
+ const current = path.extname(location);
1839
+ return current === ""
1840
+ ? `${location}${extension}`
1841
+ : `${location.slice(0, -current.length)}${extension}`;
1842
+ }
1843
+
1844
+ function passthroughBooleanOption(
1845
+ tokens: readonly string[] | undefined,
1846
+ name: string,
1847
+ ): boolean | undefined {
1848
+ let value: boolean | undefined;
1849
+ for (let index = 0; index < (tokens?.length ?? 0); index++) {
1850
+ const token = tokens?.[index];
1851
+ if (token === undefined) continue;
1852
+ if (!passthroughOptionMatches(token, name)) continue;
1853
+ const next = tokens?.[index + 1];
1854
+ if (next === "true" || next === "false" || next === "null") {
1855
+ value = next === "true";
1856
+ index++;
1857
+ } else {
1858
+ value = true;
1859
+ }
1860
+ }
1861
+ return value;
1862
+ }
1863
+
1864
+ function passthroughPathOption(
1865
+ tokens: readonly string[] | undefined,
1866
+ name: string,
1867
+ ): string | null | undefined {
1868
+ let value: string | null | undefined;
1869
+ for (let index = 0; index < (tokens?.length ?? 0); index++) {
1870
+ const token = tokens?.[index];
1871
+ if (token === undefined) continue;
1872
+ if (!passthroughOptionMatches(token, name)) continue;
1873
+ if (index + 1 < (tokens?.length ?? 0)) {
1874
+ const next = tokens?.[++index];
1875
+ value = next === "null" ? null : next;
1876
+ }
1877
+ }
1878
+ return value;
1879
+ }
1880
+
1881
+ function passthroughStringOption(
1882
+ tokens: readonly string[] | undefined,
1883
+ name: string,
1884
+ ): string | undefined {
1885
+ return passthroughPathOption(tokens, name)?.toLowerCase() ?? undefined;
1886
+ }
1887
+
1888
+ function passthroughOptionMatches(token: string, name: string): boolean {
1889
+ if (!token.startsWith("-")) return false;
1890
+ if (token.includes("=")) return false;
1891
+ return resolveFlagSpec(token)?.name === resolveFlagSpec(name)?.name;
334
1892
  }
335
1893
 
336
1894
  function collectTopologyDirectories(
@@ -367,7 +1925,14 @@ function collectInputDirectories(input: string): string[] {
367
1925
  while (stack.length !== 0) {
368
1926
  const current = stack.pop()!;
369
1927
  directories.push(current);
370
- for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
1928
+ let entries: fs.Dirent[];
1929
+ try {
1930
+ entries = fs.readdirSync(current, { withFileTypes: true });
1931
+ } catch (error) {
1932
+ if (isVanishedFilesystemEntry(error)) continue;
1933
+ throw error;
1934
+ }
1935
+ for (const entry of entries) {
371
1936
  if (entry.isDirectory()) {
372
1937
  stack.push(path.join(current, entry.name));
373
1938
  }
@@ -376,6 +1941,14 @@ function collectInputDirectories(input: string): string[] {
376
1941
  return directories;
377
1942
  }
378
1943
 
1944
+ function isVanishedFilesystemEntry(error: unknown): boolean {
1945
+ return (
1946
+ error instanceof Error &&
1947
+ "code" in error &&
1948
+ (error.code === "ENOENT" || error.code === "ENOTDIR")
1949
+ );
1950
+ }
1951
+
379
1952
  function isDirectory(location: string): boolean {
380
1953
  try {
381
1954
  return fs.statSync(location).isDirectory();
@@ -384,27 +1957,47 @@ function isDirectory(location: string): boolean {
384
1957
  }
385
1958
  }
386
1959
 
387
- function syncWatchers(
388
- watchers: Map<string, fs.FSWatcher>,
1960
+ type SynchronizedWatcher = {
1961
+ close(): void;
1962
+ on(event: "error", listener: (error: Error) => void): unknown;
1963
+ };
1964
+
1965
+ export function syncWatchers<T extends SynchronizedWatcher>(
1966
+ watchers: Map<string, T>,
389
1967
  desired: ReadonlyMap<string, string>,
390
- create: (location: string) => fs.FSWatcher,
1968
+ create: (location: string, key: string) => T,
391
1969
  onError: (location: string, error: unknown) => void,
392
- ): void {
393
- for (const [key, watcher] of watchers) {
394
- if (desired.has(key)) continue;
395
- watcher.close();
396
- watchers.delete(key);
397
- }
1970
+ shouldContinue: () => boolean = () => true,
1971
+ ): boolean {
1972
+ let complete = true;
398
1973
  for (const [key, location] of desired) {
1974
+ if (!shouldContinue()) {
1975
+ complete = false;
1976
+ break;
1977
+ }
399
1978
  if (watchers.has(key)) continue;
400
1979
  try {
401
- const watcher = create(location);
402
- watcher.on("error", (error) => onError(location, error));
1980
+ const watcher = create(location, key);
1981
+ watcher.on("error", (error) => {
1982
+ if (watchers.get(key) === watcher) {
1983
+ watchers.delete(key);
1984
+ }
1985
+ watcher.close();
1986
+ onError(location, error);
1987
+ });
403
1988
  watchers.set(key, watcher);
404
1989
  } catch (error) {
1990
+ complete = false;
405
1991
  onError(location, error);
406
1992
  }
407
1993
  }
1994
+ if (!complete) return false;
1995
+ for (const [key, watcher] of watchers) {
1996
+ if (desired.has(key)) continue;
1997
+ watcher.close();
1998
+ watchers.delete(key);
1999
+ }
2000
+ return true;
408
2001
  }
409
2002
 
410
2003
  function closeWatchers(watchers: Map<string, fs.FSWatcher>): void {
@@ -429,6 +2022,751 @@ function uniqueExistingPaths(paths: readonly string[]): string[] {
429
2022
  return [...unique.values()];
430
2023
  }
431
2024
 
2025
+ function normalizeProjectInputSnapshot(
2026
+ snapshot: ITtscProjectInputSnapshot,
2027
+ ): ITtscProjectInputSnapshot {
2028
+ const identities = createProjectInputPathIdentityContext();
2029
+ const files = new Map<string, string>();
2030
+ const globs = new Map<string, string>();
2031
+ const reloadDirectories = new Map<string, string>();
2032
+ const reloadFiles = new Map<string, string>();
2033
+ for (const file of snapshot.files) {
2034
+ const identity = identities.resolve(file);
2035
+ files.set(identity.key, identity.path);
2036
+ }
2037
+ for (const glob of snapshot.globs) {
2038
+ const identity = identities.resolve(glob);
2039
+ globs.set(identity.key, identity.path.split(path.sep).join("/"));
2040
+ }
2041
+ for (const file of snapshot.reloadFiles ?? []) {
2042
+ const identity = identities.resolve(file);
2043
+ reloadFiles.set(identity.key, identity.path);
2044
+ }
2045
+ for (const directory of snapshot.reloadDirectories ?? []) {
2046
+ const identity = identities.resolve(directory);
2047
+ reloadDirectories.set(identity.key, identity.path);
2048
+ }
2049
+ return {
2050
+ files: [...files.values()].sort(),
2051
+ globs: [...globs.values()].sort(),
2052
+ reloadDirectories: [...reloadDirectories.values()].sort(),
2053
+ reloadFiles: [...reloadFiles.values()].sort(),
2054
+ root: identities.resolve(snapshot.root).path,
2055
+ };
2056
+ }
2057
+
2058
+ function projectInputSnapshotsEqual(
2059
+ left: ITtscProjectInputSnapshot,
2060
+ right: ITtscProjectInputSnapshot,
2061
+ ): boolean {
2062
+ const identities = createProjectInputPathIdentityContext();
2063
+ return (
2064
+ // Every sibling comparison in this class decides on identity, and this one
2065
+ // is only safe today because both operands come from the same normalizer.
2066
+ identities.resolve(left.root || ".").key ===
2067
+ identities.resolve(right.root || ".").key &&
2068
+ arraysEqual(left.files, right.files) &&
2069
+ arraysEqual(left.globs, right.globs) &&
2070
+ arraysEqual(left.reloadDirectories ?? [], right.reloadDirectories ?? []) &&
2071
+ arraysEqual(left.reloadFiles ?? [], right.reloadFiles ?? [])
2072
+ );
2073
+ }
2074
+
2075
+ export function literalGlobRoot(pattern: string): string {
2076
+ const resolved = path.resolve(pattern);
2077
+ const normalized = resolved.split("\\").join("/");
2078
+ const wildcard = normalized.search(/[*?]/);
2079
+ if (wildcard === -1) return path.dirname(resolved);
2080
+ const separator = normalized.lastIndexOf("/", wildcard);
2081
+ const prefix = separator < 0 ? "." : normalized.slice(0, separator);
2082
+ const volumeRoot = path.parse(resolved).root;
2083
+ const normalizedVolumeRoot = volumeRoot.split("\\").join("/");
2084
+ if (
2085
+ prefix.length === 0 ||
2086
+ prefix === normalizedVolumeRoot.replace(/\/$/, "")
2087
+ ) {
2088
+ return volumeRoot;
2089
+ }
2090
+ return path.resolve(prefix);
2091
+ }
2092
+
2093
+ function projectInputDeclarationKey(
2094
+ kind: "file" | "glob" | "reload" | "reload-directory",
2095
+ declaration: string,
2096
+ ): string {
2097
+ return `${kind}\0${resolveProjectInputPath(declaration)}`;
2098
+ }
2099
+
2100
+ /**
2101
+ * Chooses the one stable recursive watcher root owned by a project-input
2102
+ * declaration.
2103
+ *
2104
+ * Inputs inside the project share its physical root so directory replacement
2105
+ * cannot strand a child handle. External inputs use the nearest existing
2106
+ * ancestor of their declared parent, which is the explicit boundary for
2107
+ * observing a currently missing external tree without polling every file --
2108
+ * except that the boundary never rises to a directory holding the project,
2109
+ * since such a root outranks the project's own in the active merge and leaves
2110
+ * one handle over a shared system directory to carry everything. An external
2111
+ * declaration that can only be owned that way falls back to its own tree, and
2112
+ * is left unwatched when even that would contain the project.
2113
+ */
2114
+ export function projectInputWatchDirectories(
2115
+ target: string,
2116
+ projectRoot: string,
2117
+ ): string[] {
2118
+ const root = projectInputRecursiveWatchRoot(target, projectRoot);
2119
+ return root === undefined ? [] : [root];
2120
+ }
2121
+
2122
+ /**
2123
+ * Removes recursive roots already covered by an ancestor without rewriting the
2124
+ * declaration-specific roots retained by WatchTopology.
2125
+ */
2126
+ export function projectInputActiveWatchDirectories(
2127
+ directories: Iterable<string>,
2128
+ identities = createProjectInputPathIdentityContext(),
2129
+ ): string[] {
2130
+ const unique = new Map<string, string>();
2131
+ for (const directory of directories) {
2132
+ // Coverage is decided on physical identity so two spellings of one
2133
+ // directory cannot both survive, but the surviving entry keeps the
2134
+ // caller's spelling: this function selects roots, it does not rename them.
2135
+ const identity = identities.resolve(directory);
2136
+ if (!unique.has(identity.key)) unique.set(identity.key, directory);
2137
+ }
2138
+ return [...unique]
2139
+ .filter(([key]) => {
2140
+ let ancestor = path.dirname(key);
2141
+ while (ancestor !== key) {
2142
+ if (unique.has(ancestor)) return false;
2143
+ const parent = path.dirname(ancestor);
2144
+ if (parent === ancestor) break;
2145
+ ancestor = parent;
2146
+ }
2147
+ return true;
2148
+ })
2149
+ .map(([, directory]) => directory);
2150
+ }
2151
+
2152
+ export function projectInputAvailableWatchDirectory(
2153
+ location: string,
2154
+ rejected: ReadonlySet<string>,
2155
+ identities: ProjectInputPathIdentityContext = createProjectInputPathIdentityContext(),
2156
+ projectRoot?: string,
2157
+ ): string | undefined {
2158
+ const resolvedProjectRoot =
2159
+ projectRoot === undefined ? undefined : path.resolve(projectRoot);
2160
+ let current = path.resolve(location);
2161
+ while (true) {
2162
+ const identity = identities.resolve(current);
2163
+ // Escalation obeys the same ceiling root selection does. A watcher that
2164
+ // failed once would otherwise be replaced by one over a directory holding
2165
+ // the project, which outranks the project's own root in the active merge
2166
+ // and re-opens, through the recovery path, the exact swallow that selection
2167
+ // refuses to create. Giving up the failed root is the lesser loss.
2168
+ if (
2169
+ resolvedProjectRoot !== undefined &&
2170
+ identity.key !== identities.resolve(resolvedProjectRoot).key &&
2171
+ identities.isWithin(identity.path, resolvedProjectRoot)
2172
+ ) {
2173
+ return undefined;
2174
+ }
2175
+ if (!rejected.has(identity.key)) return identity.path;
2176
+ const parent = path.dirname(current);
2177
+ if (parent === current) return undefined;
2178
+ const fallback = nearestExistingDirectory(parent);
2179
+ if (fallback === undefined) return undefined;
2180
+ current = fallback;
2181
+ }
2182
+ }
2183
+
2184
+ function projectInputRecursiveWatchRoot(
2185
+ target: string,
2186
+ projectRoot: string,
2187
+ identities = createProjectInputPathIdentityContext(),
2188
+ ): string | undefined {
2189
+ const resolvedTarget = path.resolve(target);
2190
+ const resolvedProjectRoot = path.resolve(projectRoot);
2191
+ if (identities.isWithin(resolvedProjectRoot, resolvedTarget)) {
2192
+ return nearestExistingDirectory(resolvedProjectRoot);
2193
+ }
2194
+ // An external anchor rises to the declared parent so a tree that does not
2195
+ // exist yet is still observed, and so siblings under it share one handle. It
2196
+ // may not rise past the project, though: a directory that contains the
2197
+ // project swallows the project's own root when the two are merged, and every
2198
+ // in-project declaration then rides one recursive handle over a shared system
2199
+ // directory — a temp root, or the filesystem root itself — which delivers
2200
+ // nothing. Prefer the declared parent, fall back to the target's own tree,
2201
+ // and decline rather than widen past the project.
2202
+ for (const candidate of [
2203
+ nearestExistingDirectory(path.dirname(resolvedTarget)),
2204
+ nearestExistingDirectory(resolvedTarget),
2205
+ ]) {
2206
+ if (candidate === undefined) continue;
2207
+ // The project root cannot outrank itself in the merge, so it is the one
2208
+ // container that is never a swallow — it is the owner the internal branch
2209
+ // would have chosen anyway. A declaration reached through an in-project
2210
+ // directory symlink lands here, and rejecting it would drop the hoist that
2211
+ // keeps a replaced directory from stranding a child handle.
2212
+ if (
2213
+ identities.resolve(candidate).key !==
2214
+ identities.resolve(resolvedProjectRoot).key &&
2215
+ identities.isWithin(candidate, resolvedProjectRoot)
2216
+ ) {
2217
+ continue;
2218
+ }
2219
+ return candidate;
2220
+ }
2221
+ return undefined;
2222
+ }
2223
+
2224
+ /**
2225
+ * Cheap content and physical-owner identities for a tracked file.
2226
+ *
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.
2231
+ */
2232
+ function compilerFileSnapshot(location: string): CompilerFileSnapshot {
2233
+ try {
2234
+ const stats = fs.statSync(location);
2235
+ return {
2236
+ content: `${stats.mtimeMs}:${stats.size}`,
2237
+ owner: `${stats.dev}:${stats.ino}`,
2238
+ };
2239
+ } catch {
2240
+ return { content: "", owner: "" };
2241
+ }
2242
+ }
2243
+
2244
+ function isSymbolicLink(location: string): boolean {
2245
+ try {
2246
+ return fs.lstatSync(location).isSymbolicLink();
2247
+ } catch {
2248
+ return false;
2249
+ }
2250
+ }
2251
+
2252
+ function nearestExistingDirectory(location: string): string | undefined {
2253
+ let current = path.resolve(location);
2254
+ while (true) {
2255
+ if (isDirectory(current)) return current;
2256
+ const parent = path.dirname(current);
2257
+ if (parent === current) return undefined;
2258
+ current = parent;
2259
+ }
2260
+ }
2261
+
2262
+ function matchesProjectInput(
2263
+ snapshot: ITtscProjectInputSnapshot,
2264
+ location: string,
2265
+ identities = createProjectInputPathIdentityContext(),
2266
+ ): boolean {
2267
+ const key = identities.resolve(location).key;
2268
+ return (
2269
+ snapshot.files.some((file) => identities.resolve(file).key === key) ||
2270
+ (snapshot.reloadFiles ?? []).some(
2271
+ (file) => identities.resolve(file).key === key,
2272
+ ) ||
2273
+ (snapshot.reloadDirectories ?? []).some((directory) =>
2274
+ identities.isWithin(directory, location),
2275
+ ) ||
2276
+ snapshot.globs.some((glob) =>
2277
+ matchesProjectInputGlob(glob, location, identities),
2278
+ )
2279
+ );
2280
+ }
2281
+
2282
+ /**
2283
+ * Whether a replacement at this path leaves a recursive watcher bound to the
2284
+ * object that was replaced.
2285
+ *
2286
+ * Only one backend needs the answer. Node routes a recursive watch to its own
2287
+ * per-directory implementation when the platform is neither macOS nor Windows,
2288
+ * and that implementation keys its handles by path: the handle for a directory
2289
+ * renamed away stays bound to the object that left, and any child whose name
2290
+ * survives the swap is skipped as already known. The native subtree backends
2291
+ * both other platforms use follow the path, so retiring their watcher would buy
2292
+ * nothing and would open a window in which no events are delivered.
2293
+ *
2294
+ * The answer is deliberately narrower than the rescan rule, because
2295
+ * reinstalling a root costs one watch descriptor per entry beneath it, which an
2296
+ * install storm would pay thousands of times. A directory appearing inside a
2297
+ * glob root deserves a rescan but replaces nothing a root stands on, and a
2298
+ * reload directory anchors the directory that contains it rather than itself,
2299
+ * since its fingerprint is a digest of its own immediate entries and nothing
2300
+ * below it can reach the declared corpus.
2301
+ */
2302
+ export function projectInputReplacementStrandsWatchers(
2303
+ snapshot: ITtscProjectInputSnapshot,
2304
+ location: string,
2305
+ identities = createProjectInputPathIdentityContext(),
2306
+ platform: NodeJS.Platform = process.platform,
2307
+ ): boolean {
2308
+ if (platform === "darwin" || platform === "win32") return false;
2309
+ const changed = path.resolve(location);
2310
+ if (!isDirectory(changed)) return false;
2311
+ return projectInputAnchorsDeclaration(
2312
+ snapshot,
2313
+ path.dirname(changed),
2314
+ identities,
2315
+ );
2316
+ }
2317
+
2318
+ function projectInputAnchorsDeclaration(
2319
+ snapshot: ITtscProjectInputSnapshot,
2320
+ directory: string,
2321
+ identities: ProjectInputPathIdentityContext,
2322
+ ): boolean {
2323
+ return (
2324
+ snapshot.files.some((file) => identities.isWithin(directory, file)) ||
2325
+ (snapshot.reloadFiles ?? []).some((file) =>
2326
+ identities.isWithin(directory, file),
2327
+ ) ||
2328
+ (snapshot.reloadDirectories ?? []).some(
2329
+ (entry) =>
2330
+ // A reload directory anchors the directory that contains it, never
2331
+ // itself. Its own fingerprint is a digest of its immediate entries, so
2332
+ // nothing below it can reach the corpus, and treating it as its own
2333
+ // anchor would rearm for every entry created directly inside it —
2334
+ // including `node_modules`, which contributors publish as one.
2335
+ identities.isWithin(directory, entry) &&
2336
+ identities.resolve(directory).key !== identities.resolve(entry).key,
2337
+ ) ||
2338
+ snapshot.globs.some((glob) =>
2339
+ identities.isWithin(directory, literalGlobRoot(glob)),
2340
+ )
2341
+ );
2342
+ }
2343
+
2344
+ /**
2345
+ * Decide whether an event that named no declared input can still have moved
2346
+ * one.
2347
+ *
2348
+ * The admitted set is the only bound on how often a watch session re-reads and
2349
+ * re-hashes its declared corpus, and both directions cost: too narrow drops an
2350
+ * atomic replacement, too wide re-fingerprints on every entry an install
2351
+ * creates. Exported so that boundary is pinned directly instead of being
2352
+ * inferred from a rebuild that a silent rescan and a skipped rescan produce
2353
+ * identically.
2354
+ */
2355
+ export function projectInputTopologyMayAffect(
2356
+ snapshot: ITtscProjectInputSnapshot,
2357
+ location: string,
2358
+ previous: ReadonlyMap<string, string>,
2359
+ identities = createProjectInputPathIdentityContext(),
2360
+ ): boolean {
2361
+ const changed = path.resolve(location);
2362
+ const anchors = (directory: string): boolean =>
2363
+ projectInputAnchorsDeclaration(snapshot, directory, identities);
2364
+ // An atomic replacement never names the declared file whose bytes it changed;
2365
+ // it names the directory that was swapped, and that directory can be one the
2366
+ // declaration does not contain — renaming `docs` away reports the arriving
2367
+ // `docs-old`, not `docs`. So a directory event is admitted from where it
2368
+ // happened rather than from what it contains: its own parent must already lie
2369
+ // on the path to a declared input. A tree no declaration reaches, such as
2370
+ // `node_modules` under an ordinary project, then costs nothing per created
2371
+ // entry instead of a population rescan and a full content re-fingerprint. A
2372
+ // glob whose literal root covers that tree still admits every directory
2373
+ // beneath it through the branch below, because a directory appearing inside a
2374
+ // glob root can hold matches; the declaration decides that reach, not this
2375
+ // rule.
2376
+ if (isDirectory(changed) && anchors(path.dirname(changed))) return true;
2377
+ return (
2378
+ anchors(changed) ||
2379
+ (snapshot.reloadDirectories ?? []).some((directory) =>
2380
+ identities.isWithin(directory, changed),
2381
+ ) ||
2382
+ snapshot.globs.some((glob) => {
2383
+ const root = literalGlobRoot(glob);
2384
+ if (identities.isWithin(changed, root)) return true;
2385
+ if (identities.isWithin(root, changed) === false) return false;
2386
+ if (isDirectory(changed)) return true;
2387
+ return [...previous.values()].some((input) =>
2388
+ identities.isWithin(changed, input),
2389
+ );
2390
+ })
2391
+ );
2392
+ }
2393
+
2394
+ export function projectInputEventShouldNotify(input: {
2395
+ contentChanged: boolean;
2396
+ directlyMatched: boolean;
2397
+ membershipChanged: boolean;
2398
+ }): boolean {
2399
+ return input.contentChanged || input.membershipChanged;
2400
+ }
2401
+
2402
+ function projectInputChangedPaths(input: {
2403
+ next: ReadonlyMap<string, string>;
2404
+ nextFingerprints: ReadonlyMap<string, string>;
2405
+ previous: ReadonlyMap<string, string>;
2406
+ previousFingerprints: ReadonlyMap<string, string>;
2407
+ }): string[] {
2408
+ const changed = new Map<string, string>();
2409
+ const keys = new Set([
2410
+ ...input.previous.keys(),
2411
+ ...input.next.keys(),
2412
+ ...input.previousFingerprints.keys(),
2413
+ ...input.nextFingerprints.keys(),
2414
+ ]);
2415
+ for (const key of keys) {
2416
+ if (
2417
+ input.previous.has(key) === input.next.has(key) &&
2418
+ input.previousFingerprints.get(key) === input.nextFingerprints.get(key)
2419
+ ) {
2420
+ continue;
2421
+ }
2422
+ const location = input.next.get(key) ?? input.previous.get(key);
2423
+ if (location !== undefined) changed.set(key, location);
2424
+ }
2425
+ return [...changed.values()];
2426
+ }
2427
+
2428
+ /**
2429
+ * Classify an exact execution-selection input ahead of ordinary project data.
2430
+ *
2431
+ * `changedInputs` carries fingerprint or membership deltas, so a filename-less
2432
+ * event can still select the cold lane. A named exact event selects the cold
2433
+ * lane only after the surrounding change detector admits the event; unchanged
2434
+ * bytes remain quiet before this classifier is observed.
2435
+ */
2436
+ export function projectInputReloadEventShouldNotify(input: {
2437
+ causedBy?: readonly string[];
2438
+ changed?: string;
2439
+ changedInputs: readonly string[];
2440
+ reloadDirectories?: readonly string[];
2441
+ reloadFiles: readonly string[];
2442
+ globs?: readonly string[];
2443
+ }): boolean {
2444
+ const identities = createProjectInputPathIdentityContext();
2445
+ const reloadFiles = new Set(
2446
+ input.reloadFiles.map((location) => identities.resolve(location).key),
2447
+ );
2448
+ const reloadDirectories = (input.reloadDirectories ?? []).map((location) =>
2449
+ identities.resolve(location),
2450
+ );
2451
+ // Territory a declared glob is rooted at, judged against the resolution
2452
+ // directory that would otherwise claim it. A project root is published as a
2453
+ // resolution directory because the config lives there, so the directory a
2454
+ // glob is rooted at reads as a new immediate entry the moment it appears --
2455
+ // and appearing is what a declared population does. The data lane already
2456
+ // reports it, with program invalidation when the membership moved, which is
2457
+ // the cold-Program-same-process transition that belongs to data.
2458
+ //
2459
+ // Data can only carve out strictly below a resolution directory. A glob
2460
+ // rooted on that directory, or above it -- `literalGlobRoot` answers with the
2461
+ // volume root for a pattern with no literal prefix -- would otherwise exempt
2462
+ // everything the directory exists to classify, and the selection lane would
2463
+ // retire in silence. Its role predates any glob drawn around it.
2464
+ //
2465
+ // Only glob roots, either way. A declared file sitting directly in a
2466
+ // resolution directory is still a selection surface: its own bytes are what a
2467
+ // project rule reads to decide, once per execution.
2468
+ const globRoots = (input.globs ?? []).map((glob) =>
2469
+ identities.resolve(literalGlobRoot(glob)),
2470
+ );
2471
+ const exemptedFrom = (
2472
+ directory: ProjectInputPathIdentity,
2473
+ location: string,
2474
+ ): boolean =>
2475
+ globRoots.some(
2476
+ (globRoot) =>
2477
+ globRoot.key !== directory.key &&
2478
+ identities.isWithin(directory.path, globRoot.path) &&
2479
+ identities.isWithin(globRoot.path, location),
2480
+ );
2481
+ // A reload directory is a non-recursive surface: its digest covers its
2482
+ // immediate entries' names, kinds, and link targets, so it moves only when the
2483
+ // directory itself moves or when an entry it holds directly does. Matching the
2484
+ // whole subtree instead makes a resolution ancestor -- the lint config graph
2485
+ // publishes every node_modules level it searched, up to the filesystem root --
2486
+ // read every edit beneath it as a selection change, which restarts the sidecar
2487
+ // on each keystroke and ends warm reuse for any project inside one.
2488
+ const holdsAsImmediateEntry = (location: string): boolean => {
2489
+ const parent = identities.resolve(path.dirname(location)).key;
2490
+ return reloadDirectories.some(
2491
+ (directory) =>
2492
+ directory.key === parent && exemptedFrom(directory, location) === false,
2493
+ );
2494
+ };
2495
+ const namesDirectory = (location: string): boolean => {
2496
+ const key = identities.resolve(location).key;
2497
+ return reloadDirectories.some((directory) => directory.key === key);
2498
+ };
2499
+ // A digest delta on the directory itself is the only signal there is when the
2500
+ // entry that appeared is not a declared match, so it cannot simply be dropped
2501
+ // -- but it also cannot say what appeared. The event being classified in the
2502
+ // same pass can: when it names data, the delta it caused is that data's, and
2503
+ // when it names anything else, or nothing at all, the safe reading is that
2504
+ // resolution moved. A missed reselection is a wrong answer; a spare restart is
2505
+ // a slow one.
2506
+ const explains = (directory: ProjectInputPathIdentity): boolean => {
2507
+ const causes =
2508
+ input.causedBy ?? (input.changed === undefined ? [] : [input.changed]);
2509
+ // Only an immediate entry can move this directory's digest, so only an
2510
+ // immediate entry can account for the delta. Asking whether the event was
2511
+ // data somewhere else answers a different directory's question, and one
2512
+ // exemption would then cancel every other directory's evidence.
2513
+ return causes.some((location) => {
2514
+ const parent = identities.resolve(path.dirname(location)).key;
2515
+ return parent === directory.key && exemptedFrom(directory, location);
2516
+ });
2517
+ };
2518
+ const isReloadDirectoryEvent = (location: string): boolean =>
2519
+ namesDirectory(location) || holdsAsImmediateEntry(location);
2520
+ const isReloadDirectoryDelta = (location: string): boolean => {
2521
+ if (holdsAsImmediateEntry(location)) return true;
2522
+ const key = identities.resolve(location).key;
2523
+ return reloadDirectories.some(
2524
+ (directory) => directory.key === key && explains(directory) === false,
2525
+ );
2526
+ };
2527
+ return (
2528
+ (input.changed !== undefined &&
2529
+ (reloadFiles.has(identities.resolve(input.changed).key) ||
2530
+ isReloadDirectoryEvent(input.changed))) ||
2531
+ input.changedInputs.some(
2532
+ (location) =>
2533
+ reloadFiles.has(identities.resolve(location).key) ||
2534
+ isReloadDirectoryDelta(location),
2535
+ )
2536
+ );
2537
+ }
2538
+
2539
+ /**
2540
+ * Return whether a project-input population transition can reshape a Program.
2541
+ *
2542
+ * JSON is data to a ProjectRule but may simultaneously be a `resolveJsonModule`
2543
+ * source. TypeScript and JavaScript paths can likewise overlap a project-input
2544
+ * declaration. Their creation or deletion therefore requires a cold Program
2545
+ * inside the existing resident process. A filename-less event cannot identify
2546
+ * the changed member and is conservatively invalidating whenever the population
2547
+ * moved.
2548
+ */
2549
+ export function projectInputMembershipInvalidatesProgram(input: {
2550
+ changed?: string;
2551
+ changedInputs?: readonly string[];
2552
+ contentChanged?: boolean;
2553
+ next: ReadonlyMap<string, string>;
2554
+ previous: ReadonlyMap<string, string>;
2555
+ }): boolean {
2556
+ if (
2557
+ input.contentChanged === true &&
2558
+ (
2559
+ input.changedInputs ??
2560
+ (input.changed === undefined ? [] : [input.changed])
2561
+ ).some(
2562
+ (location) => path.basename(location).toLowerCase() === "package.json",
2563
+ )
2564
+ ) {
2565
+ return true;
2566
+ }
2567
+ if (mapsEqual(input.previous, input.next)) return false;
2568
+ if (input.changed === undefined) return true;
2569
+ for (const [key, location] of input.previous) {
2570
+ if (
2571
+ input.next.has(key) === false &&
2572
+ projectInputPathMayAffectProgram(location)
2573
+ ) {
2574
+ return true;
2575
+ }
2576
+ }
2577
+ for (const [key, location] of input.next) {
2578
+ if (
2579
+ input.previous.has(key) === false &&
2580
+ projectInputPathMayAffectProgram(location)
2581
+ ) {
2582
+ return true;
2583
+ }
2584
+ }
2585
+ return false;
2586
+ }
2587
+
2588
+ function projectInputPathMayAffectProgram(location: string): boolean {
2589
+ const extension = path.extname(location).toLowerCase();
2590
+ return extension === ".json" || isCompilerEmittableSourceExtension(extension);
2591
+ }
2592
+
2593
+ function compilerMembershipChange(
2594
+ previous: ReadonlyMap<string, string>,
2595
+ next: ReadonlyMap<string, string>,
2596
+ ): string[] {
2597
+ const changed = new Map<string, string>();
2598
+ for (const [key, location] of previous) {
2599
+ if (next.has(key) === false) changed.set(key, location);
2600
+ }
2601
+ for (const [key, location] of next) {
2602
+ if (previous.has(key) === false) changed.set(key, location);
2603
+ }
2604
+ return [...changed.values()].sort();
2605
+ }
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
+
2645
+ function fingerprintProjectInputMatches(
2646
+ matches: ReadonlyMap<string, string>,
2647
+ ): Map<string, string> {
2648
+ const fingerprints = new Map<string, string>();
2649
+ for (const [key, location] of matches) {
2650
+ fingerprints.set(
2651
+ key,
2652
+ isDirectory(location)
2653
+ ? fingerprintProjectInputDirectory(location)
2654
+ : fingerprintProjectInputFile(location),
2655
+ );
2656
+ }
2657
+ return fingerprints;
2658
+ }
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
+
2671
+ function fingerprintProjectInputDirectory(location: string): string {
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
+ }
2690
+ }
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
+ }
2698
+ }
2699
+
2700
+ function matchesProjectInputGlob(
2701
+ pattern: string,
2702
+ location: string,
2703
+ identities = createProjectInputPathIdentityContext(),
2704
+ ): boolean {
2705
+ const root = identities.resolve(literalGlobRoot(pattern));
2706
+ const candidate = identities.resolve(location);
2707
+ if (!isProjectInputPathIdentityWithin(root.key, candidate.key)) return false;
2708
+ const sensitive = identities.caseSensitive(root.path);
2709
+ const patternParts = path
2710
+ .relative(root.path, identities.resolve(pattern).path)
2711
+ .split(path.sep);
2712
+ const candidateParts = path
2713
+ .relative(root.path, candidate.path)
2714
+ .split(path.sep);
2715
+ return matchProjectInputGlobParts(
2716
+ sensitive
2717
+ ? patternParts
2718
+ : patternParts.map((segment) => segment.toLowerCase()),
2719
+ sensitive
2720
+ ? candidateParts
2721
+ : candidateParts.map((segment) => segment.toLowerCase()),
2722
+ );
2723
+ }
2724
+
2725
+ function matchProjectInputGlobParts(
2726
+ pattern: readonly string[],
2727
+ candidate: readonly string[],
2728
+ ): boolean {
2729
+ const memo = new Map<string, boolean>();
2730
+ const visit = (patternIndex: number, candidateIndex: number): boolean => {
2731
+ const key = `${String(patternIndex)}:${String(candidateIndex)}`;
2732
+ const cached = memo.get(key);
2733
+ if (cached !== undefined) return cached;
2734
+ let matched: boolean;
2735
+ if (patternIndex === pattern.length) {
2736
+ matched = candidateIndex === candidate.length;
2737
+ } else if (pattern[patternIndex] === "**") {
2738
+ matched =
2739
+ visit(patternIndex + 1, candidateIndex) ||
2740
+ (candidateIndex !== candidate.length &&
2741
+ visit(patternIndex, candidateIndex + 1));
2742
+ } else {
2743
+ matched =
2744
+ candidateIndex !== candidate.length &&
2745
+ matchProjectInputGlobSegment(
2746
+ pattern[patternIndex]!,
2747
+ candidate[candidateIndex]!,
2748
+ ) &&
2749
+ visit(patternIndex + 1, candidateIndex + 1);
2750
+ }
2751
+ memo.set(key, matched);
2752
+ return matched;
2753
+ };
2754
+ return visit(0, 0);
2755
+ }
2756
+
2757
+ function matchProjectInputGlobSegment(
2758
+ pattern: string,
2759
+ candidate: string,
2760
+ ): boolean {
2761
+ let source = "^";
2762
+ for (const char of pattern) {
2763
+ if (char === "*") source += ".*";
2764
+ else if (char === "?") source += ".";
2765
+ else source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
2766
+ }
2767
+ return new RegExp(`${source}$`, "u").test(candidate);
2768
+ }
2769
+
432
2770
  function mapsEqual(
433
2771
  left: ReadonlyMap<string, string>,
434
2772
  right: ReadonlyMap<string, string>,
@@ -456,11 +2794,93 @@ function isPathWithin(root: string, candidate: string): boolean {
456
2794
  );
457
2795
  }
458
2796
 
2797
+ /**
2798
+ * Plan one compiler-directory event without relying on backend timing.
2799
+ *
2800
+ * POSIX file watchers own ordinary content changes. A named rename re-arms the
2801
+ * replaced file; an unnamed event conservatively re-arms and reports every
2802
+ * surviving tracked input below the watch root. Windows has no per-file
2803
+ * watchers here, so both named and unnamed directory events report inputs.
2804
+ */
2805
+ export function planCompilerDirectoryWatchEvent(input: {
2806
+ changed?: string;
2807
+ event: string;
2808
+ exists(location: string): boolean;
2809
+ location: string;
2810
+ platform: NodeJS.Platform;
2811
+ trackedFiles: ReadonlyMap<string, string>;
2812
+ }): CompilerDirectoryWatchEventPlan {
2813
+ const candidates =
2814
+ input.changed === undefined
2815
+ ? [...input.trackedFiles.values()].filter(
2816
+ (file) =>
2817
+ input.exists(file) &&
2818
+ isPathWithin(input.location, path.resolve(file)),
2819
+ )
2820
+ : input.trackedFiles.has(
2821
+ pathKeyForPlatform(input.changed, input.platform),
2822
+ ) && input.exists(input.changed)
2823
+ ? [input.changed]
2824
+ : [];
2825
+ if (input.changed === undefined) {
2826
+ return {
2827
+ changes: candidates,
2828
+ rearm: input.platform === "win32" ? [] : candidates,
2829
+ refresh: true,
2830
+ };
2831
+ }
2832
+ if (candidates.length === 0) {
2833
+ return { changes: [], rearm: [], refresh: true };
2834
+ }
2835
+ if (input.platform === "win32") {
2836
+ return { changes: candidates, rearm: [], refresh: false };
2837
+ }
2838
+ if (input.event === "rename") {
2839
+ return { changes: candidates, rearm: candidates, refresh: false };
2840
+ }
2841
+ return { changes: [], rearm: [], refresh: false };
2842
+ }
2843
+
459
2844
  function isRecord(value: unknown): value is Record<string, unknown> {
460
2845
  return typeof value === "object" && value !== null;
461
2846
  }
462
2847
 
2848
+ /**
2849
+ * Resolve the spelling a filesystem watcher must be registered under.
2850
+ *
2851
+ * A watch declaration keeps its lexical spelling, because classification,
2852
+ * containment, and notification are all expressed in the caller's own paths.
2853
+ * The backend needs the canonical spelling instead. libuv stores the directory
2854
+ * string it was handed, expands each delivered event that still exists on disk
2855
+ * to its long path, and then requires the stored string to be that expansion's
2856
+ * prefix. A short (8.3) component makes the two disagree, and the resulting
2857
+ * assertion aborts the whole process (libuv issue 5010; upstream now returns an
2858
+ * error instead, but the bundled libuv every supported Node ships still
2859
+ * asserts). `os.tmpdir()` routinely yields such a path, so ordinary projects
2860
+ * reach it, not only tests. macOS matches events against the watched path
2861
+ * resolved through symlinks, so `/var` and `/private/var` must not be mixed
2862
+ * either. Resolving here keeps every backend comparing two canonical spellings
2863
+ * while callers keep resolving events against what they declared.
2864
+ */
2865
+ function watcherRegistrationPath(location: string): string {
2866
+ try {
2867
+ return fs.realpathSync.native?.(location) ?? fs.realpathSync(location);
2868
+ } catch {
2869
+ // A vanished target makes fs.watch throw anyway, which syncWatchers already
2870
+ // routes to the error path; an unreadable ancestor is better watched under
2871
+ // its declared spelling than not watched at all.
2872
+ return location;
2873
+ }
2874
+ }
2875
+
463
2876
  function pathKey(location: string): string {
2877
+ return pathKeyForPlatform(location, process.platform);
2878
+ }
2879
+
2880
+ function pathKeyForPlatform(
2881
+ location: string,
2882
+ platform: NodeJS.Platform,
2883
+ ): string {
464
2884
  const resolved = path.resolve(location);
465
- return process.platform === "win32" ? resolved.toLowerCase() : resolved;
2885
+ return platform === "win32" ? resolved.toLowerCase() : resolved;
466
2886
  }