ttsc 0.20.1 → 0.22.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 (95) 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/internal/lspserver/lsp_document_diagnostics_omits_cached_project_when_current_result_has_none_test.go +48 -0
  5. package/internal/lspserver/lsp_executable_project_input_change_requests_launcher_restart_test.go +98 -0
  6. package/internal/lspserver/lsp_external_watched_change_retains_program_test.go +53 -0
  7. package/internal/lspserver/lsp_hints_refresh_scheduled_by_editor_events_test.go +9 -0
  8. package/internal/lspserver/lsp_native_plugin_source.go +171 -55
  9. package/internal/lspserver/lsp_native_resident.go +110 -9
  10. package/internal/lspserver/lsp_plugin.go +6 -0
  11. package/internal/lspserver/lsp_project_context_transport_identity_test.go +43 -0
  12. package/internal/lspserver/lsp_project_diagnostics.go +235 -0
  13. package/internal/lspserver/lsp_project_diagnostics_capability_is_independent_test.go +67 -0
  14. package/internal/lspserver/lsp_project_diagnostics_program_input_overlap_refreshes_all_producers_test.go +78 -0
  15. package/internal/lspserver/lsp_project_diagnostics_refresh_accumulates_owner_freshness_test.go +65 -0
  16. package/internal/lspserver/lsp_project_diagnostics_refresh_keeps_each_producer_last_good_test.go +90 -0
  17. package/internal/lspserver/lsp_project_diagnostics_refreshes_only_input_owners_test.go +62 -0
  18. package/internal/lspserver/lsp_project_diagnostics_resident_unsupported_falls_back_test.go +123 -0
  19. package/internal/lspserver/lsp_project_diagnostics_resume_preserves_generation_test.go +52 -0
  20. package/internal/lspserver/lsp_project_diagnostics_translates_relative_client_project_test.go +90 -0
  21. package/internal/lspserver/lsp_project_input_ownership_resolves_declared_spellings_test.go +95 -0
  22. package/internal/lspserver/lsp_project_input_registration.go +504 -0
  23. package/internal/lspserver/lsp_project_input_registration_rechecks_selection_baseline_test.go +71 -0
  24. package/internal/lspserver/lsp_project_input_registration_reconciles_rejected_pending_request_test.go +268 -0
  25. package/internal/lspserver/lsp_project_input_registration_shapes_patterns_test.go +94 -0
  26. package/internal/lspserver/lsp_project_input_watchers_anchor_missing_paths_test.go +48 -0
  27. package/internal/lspserver/lsp_project_inputs.go +944 -0
  28. package/internal/lspserver/lsp_project_inputs_match_missing_exact_and_glob_test.go +129 -0
  29. package/internal/lspserver/lsp_project_inputs_reject_invalid_and_foreign_snapshots_test.go +110 -0
  30. package/internal/lspserver/lsp_proxy.go +591 -28
  31. package/internal/lspserver/lsp_reload_directories_compare_immediate_topology_test.go +188 -0
  32. package/internal/lspserver/lsp_reload_file_fingerprint_preserves_raw_symlink_target_test.go +47 -0
  33. package/internal/lspserver/lsp_resident_falls_back_when_serve_unsupported_test.go +1 -1
  34. package/internal/lspserver/lsp_resident_invalidation_broadcasts_program_input_overlap_test.go +60 -0
  35. package/internal/lspserver/lsp_resident_invalidation_targets_project_input_owners_test.go +51 -0
  36. package/internal/lspserver/lsp_same_transport_entries_are_deduplicated_test.go +66 -0
  37. package/lib/compiler/internal/project/readProjectConfig.d.ts +5 -3
  38. package/lib/compiler/internal/project/readProjectConfig.js +46 -14
  39. package/lib/compiler/internal/project/readProjectConfig.js.map +1 -1
  40. package/lib/compiler/internal/residentCheckProcess.d.ts +46 -0
  41. package/lib/compiler/internal/residentCheckProcess.js +227 -0
  42. package/lib/compiler/internal/residentCheckProcess.js.map +1 -0
  43. package/lib/compiler/internal/resolveEmittedJavaScript.d.ts +2 -1
  44. package/lib/compiler/internal/resolveEmittedJavaScript.js +35 -18
  45. package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
  46. package/lib/compiler/internal/runBuild.d.ts +64 -1
  47. package/lib/compiler/internal/runBuild.js +484 -7
  48. package/lib/compiler/internal/runBuild.js.map +1 -1
  49. package/lib/compiler/internal/runSingleFileEmit.js +1 -0
  50. package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
  51. package/lib/flags/parser.js +13 -17
  52. package/lib/flags/parser.js.map +1 -1
  53. package/lib/flags/schema.d.ts +10 -3
  54. package/lib/flags/schema.js +99 -0
  55. package/lib/flags/schema.js.map +1 -1
  56. package/lib/internal/projectInputPathIdentity.d.ts +23 -0
  57. package/lib/internal/projectInputPathIdentity.js +222 -0
  58. package/lib/internal/projectInputPathIdentity.js.map +1 -0
  59. package/lib/launcher/internal/runTtsc.d.ts +17 -0
  60. package/lib/launcher/internal/runTtsc.js +121 -88
  61. package/lib/launcher/internal/runTtsc.js.map +1 -1
  62. package/lib/launcher/internal/runTtscserver.d.ts +13 -1
  63. package/lib/launcher/internal/runTtscserver.js +415 -45
  64. package/lib/launcher/internal/runTtscserver.js.map +1 -1
  65. package/lib/launcher/internal/singleFileOutput.d.ts +16 -0
  66. package/lib/launcher/internal/singleFileOutput.js +103 -0
  67. package/lib/launcher/internal/singleFileOutput.js.map +1 -0
  68. package/lib/launcher/internal/watchTopology.d.ts +214 -2
  69. package/lib/launcher/internal/watchTopology.js +1560 -41
  70. package/lib/launcher/internal/watchTopology.js.map +1 -1
  71. package/lib/plugin/internal/loadProjectPlugins.d.ts +1 -0
  72. package/lib/plugin/internal/loadProjectPlugins.js +5 -0
  73. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  74. package/lib/structures/ITtscPluginCapabilities.d.ts +31 -0
  75. package/lib/structures/internal/ITtscProjectInputSnapshot.d.ts +29 -0
  76. package/lib/structures/internal/ITtscProjectInputSnapshot.js +3 -0
  77. package/lib/structures/internal/ITtscProjectInputSnapshot.js.map +1 -0
  78. package/lib/structures/internal/TtscSingleFileEmitOptions.d.ts +5 -0
  79. package/package.json +8 -8
  80. package/src/compiler/internal/project/readProjectConfig.ts +62 -8
  81. package/src/compiler/internal/residentCheckProcess.ts +279 -0
  82. package/src/compiler/internal/resolveEmittedJavaScript.ts +43 -21
  83. package/src/compiler/internal/runBuild.ts +728 -12
  84. package/src/compiler/internal/runSingleFileEmit.ts +1 -0
  85. package/src/flags/parser.ts +17 -17
  86. package/src/flags/schema.ts +111 -3
  87. package/src/internal/projectInputPathIdentity.ts +273 -0
  88. package/src/launcher/internal/runTtsc.ts +141 -117
  89. package/src/launcher/internal/runTtscserver.ts +561 -54
  90. package/src/launcher/internal/singleFileOutput.ts +133 -0
  91. package/src/launcher/internal/watchTopology.ts +2008 -59
  92. package/src/plugin/internal/loadProjectPlugins.ts +8 -0
  93. package/src/structures/ITtscPluginCapabilities.ts +34 -0
  94. package/src/structures/internal/ITtscProjectInputSnapshot.ts +29 -0
  95. package/src/structures/internal/TtscSingleFileEmitOptions.ts +5 -0
@@ -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,31 @@ 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
+ onProjectInputWatchRoots?(roots: readonly string[]): void;
22
40
  onTopologyChange(): void;
23
41
  };
24
42
 
43
+ export type WatchInputChange = {
44
+ /** Keep the resident process but cold-load its compiler Program. */
45
+ invalidate?: boolean;
46
+ kind: "compiler" | "config" | "plugin" | "project";
47
+ path?: string;
48
+ };
49
+
25
50
  type ResolvedWatchTopology = {
51
+ analysisOnly: boolean;
26
52
  directories: Map<string, string>;
27
53
  files: Map<string, string>;
54
+ outputFiles: Map<string, string>;
28
55
  outputs: Map<string, string>;
56
+ reloadFiles: Map<string, string>;
57
+ };
58
+
59
+ export type CompilerDirectoryWatchEventPlan = {
60
+ changes: string[];
61
+ rearm: string[];
62
+ refresh: boolean;
29
63
  };
30
64
 
31
65
  /**
@@ -37,14 +71,39 @@ type ResolvedWatchTopology = {
37
71
  * outputs are filtered before any watcher is installed.
38
72
  */
39
73
  export class WatchTopology {
74
+ private analysisOnly = false;
75
+ private closed = false;
40
76
  private directories = new Map<string, string>();
41
77
  private directoryWatchers = new Map<string, fs.FSWatcher>();
42
78
  private extraInputs: readonly string[] = [];
43
79
  private extraWatchers = new Map<string, fs.FSWatcher>();
80
+ private compilerFileStamps = new Map<string, string>();
44
81
  private files = new Map<string, string>();
45
82
  private fileWatchers = new Map<string, fs.FSWatcher>();
46
83
  private observedDirectories = new Map<string, string>();
84
+ private outputFiles = new Map<string, string>();
47
85
  private outputs = new Map<string, string>();
86
+ private projectInputFingerprints = new Map<string, string>();
87
+ private projectInputMatches = new Map<string, string>();
88
+ private projectInputs: ITtscProjectInputSnapshot = {
89
+ files: [],
90
+ globs: [],
91
+ reloadDirectories: [],
92
+ reloadFiles: [],
93
+ root: "",
94
+ };
95
+ private declaredProjectInputs: ITtscProjectInputSnapshot = {
96
+ files: [],
97
+ globs: [],
98
+ reloadDirectories: [],
99
+ reloadFiles: [],
100
+ root: "",
101
+ };
102
+ private projectInputRejectedWatchRoots = new Set<string>();
103
+ private projectInputWatchRoots = new Map<string, string>();
104
+ private projectInputWatchers = new Map<string, fs.FSWatcher>();
105
+ private projectInputLinkWatchers = new Map<string, fs.FSWatcher>();
106
+ private reloadFiles = new Map<string, string>();
48
107
 
49
108
  public constructor(
50
109
  private readonly options: WatchTopologyOptions,
@@ -54,18 +113,61 @@ export class WatchTopology {
54
113
  /** Re-resolve compiler inputs and notify only when their membership changed. */
55
114
  public refresh(notify: boolean): void {
56
115
  const next = resolveWatchTopology(this.options, this.extraInputs);
116
+ const projectInputProgramChange =
117
+ next.analysisOnly &&
118
+ mapsEqual(this.reloadFiles, next.reloadFiles) &&
119
+ mapsEqual(this.outputFiles, next.outputFiles) &&
120
+ mapsEqual(this.outputs, next.outputs)
121
+ ? projectInputCompilerMembershipChange(
122
+ this.projectInputs,
123
+ this.files,
124
+ next.files,
125
+ )
126
+ : undefined;
57
127
  const changed =
128
+ this.analysisOnly !== next.analysisOnly ||
58
129
  mapsEqual(this.files, next.files) === false ||
59
130
  mapsEqual(this.directories, next.directories) === false ||
60
- mapsEqual(this.outputs, next.outputs) === false;
131
+ mapsEqual(this.outputFiles, next.outputFiles) === false ||
132
+ mapsEqual(this.outputs, next.outputs) === false ||
133
+ mapsEqual(this.reloadFiles, next.reloadFiles) === false;
134
+ this.analysisOnly = next.analysisOnly;
61
135
  this.files = next.files;
136
+ // Stamp the tracked set as it is resolved, so the first event that cannot
137
+ // name what changed compares against the state the compiler just saw rather
138
+ // than against nothing, which would make it nominate everything once.
139
+ for (const key of [...this.compilerFileStamps.keys()]) {
140
+ if (!next.files.has(key)) this.compilerFileStamps.delete(key);
141
+ }
142
+ for (const [key, file] of next.files) {
143
+ // Only a file with no stamp yet is seeded. Restamping one that already
144
+ // has a baseline would advance it past a change nobody reported, and the
145
+ // next unnamed event would then read that change as no change at all.
146
+ if (!this.compilerFileStamps.has(key)) {
147
+ this.compilerFileStamps.set(key, compilerFileStamp(file));
148
+ }
149
+ }
62
150
  this.directories = next.directories;
151
+ this.outputFiles = next.outputFiles;
63
152
  this.outputs = next.outputs;
153
+ this.reloadFiles = next.reloadFiles;
64
154
  this.syncFileWatchers();
65
155
  this.syncDirectoryWatchers();
66
156
  this.syncExtraWatchers();
157
+ this.syncProjectInputWatchers();
67
158
  if (notify && changed) {
68
- this.callbacks.onTopologyChange();
159
+ if (projectInputProgramChange !== undefined) {
160
+ this.callbacks.onInputChange({
161
+ invalidate: true,
162
+ kind: "project",
163
+ path:
164
+ projectInputProgramChange.length === 1
165
+ ? projectInputProgramChange[0]
166
+ : undefined,
167
+ });
168
+ } else {
169
+ this.callbacks.onTopologyChange();
170
+ }
69
171
  }
70
172
  }
71
173
 
@@ -77,62 +179,220 @@ export class WatchTopology {
77
179
  this.refresh(false);
78
180
  }
79
181
 
182
+ /**
183
+ * Reconcile project-rule dependencies, retaining absent files and empty glob
184
+ * populations as live topology.
185
+ */
186
+ public setProjectInputs(inputs: ITtscProjectInputSnapshot): void {
187
+ const next = normalizeProjectInputSnapshot(inputs);
188
+ // The declared spellings are recorded even when the normalized snapshot did
189
+ // not move, because a republication can carry a new alias for identities
190
+ // that already matched, and anchoring the spelling that was retired would
191
+ // leave the live one unwatched.
192
+ this.declaredProjectInputs =
193
+ inputs.declared === undefined
194
+ ? inputs
195
+ : { ...inputs.declared, root: inputs.root };
196
+ if (projectInputSnapshotsEqual(this.projectInputs, next)) {
197
+ this.syncProjectInputWatchers();
198
+ return;
199
+ }
200
+ this.projectInputs = next;
201
+ this.projectInputRejectedWatchRoots.clear();
202
+ const declarations = new Set(
203
+ [
204
+ next,
205
+ inputs,
206
+ { ...(inputs.declared ?? inputs), root: inputs.root },
207
+ ].flatMap((snapshot) => [
208
+ ...snapshot.files.map((file) =>
209
+ projectInputDeclarationKey("file", file),
210
+ ),
211
+ ...snapshot.globs.map((glob) =>
212
+ projectInputDeclarationKey("glob", glob),
213
+ ),
214
+ ...(snapshot.reloadFiles ?? []).map((file) =>
215
+ projectInputDeclarationKey("reload", file),
216
+ ),
217
+ ...(snapshot.reloadDirectories ?? []).map((directory) =>
218
+ projectInputDeclarationKey("reload-directory", directory),
219
+ ),
220
+ ]),
221
+ );
222
+ for (const key of this.projectInputWatchRoots.keys()) {
223
+ if (!declarations.has(key)) this.projectInputWatchRoots.delete(key);
224
+ }
225
+ this.projectInputMatches = this.collectProjectInputMatches();
226
+ this.projectInputFingerprints = fingerprintProjectInputMatches(
227
+ this.projectInputMatches,
228
+ );
229
+ this.syncProjectInputWatchers();
230
+ }
231
+
80
232
  /** Close every watcher so SIGINT/SIGTERM can drain the event loop. */
81
233
  public close(): void {
234
+ this.closed = true;
82
235
  closeWatchers(this.fileWatchers);
83
236
  closeWatchers(this.directoryWatchers);
84
237
  closeWatchers(this.extraWatchers);
238
+ closeWatchers(this.projectInputWatchers);
239
+ closeWatchers(this.projectInputLinkWatchers);
85
240
  }
86
241
 
87
242
  private syncFileWatchers(): void {
243
+ const files =
244
+ process.platform === "win32" ? new Map<string, string>() : this.files;
88
245
  syncWatchers(
89
246
  this.fileWatchers,
90
- this.files,
247
+ files,
91
248
  (location) =>
92
- fs.watch(location, { persistent: true }, () => {
93
- this.callbacks.onInputChange();
94
- }),
249
+ fs.watch(
250
+ watcherRegistrationPath(location),
251
+ { persistent: true },
252
+ () => {
253
+ // A per-file watcher fires on any filesystem attention its target
254
+ // receives, and it carries no filename to distinguish an edit from
255
+ // a touch. It answers the same question the unnamed directory event
256
+ // answers, so it answers it the same way: from the bytes.
257
+ if (!this.compilerFileMoved(location)) return;
258
+ this.callbacks.onInputChange({
259
+ kind: this.classifyCompilerInput(location),
260
+ path: location,
261
+ });
262
+ },
263
+ ),
95
264
  (location, error) => this.callbacks.onError(location, error),
96
265
  );
97
266
  }
98
267
 
268
+ /** Whether a tracked file's bytes differ from the last stamp taken. */
269
+ private compilerFileMoved(location: string): boolean {
270
+ const key = pathKey(location);
271
+ const stamp = compilerFileStamp(location);
272
+ if (this.compilerFileStamps.get(key) === stamp) return false;
273
+ this.compilerFileStamps.set(key, stamp);
274
+ return true;
275
+ }
276
+
99
277
  private syncDirectoryWatchers(): void {
100
278
  const desired = new Map(this.directories);
101
279
  for (const [key, location] of this.observedDirectories) {
102
280
  if (
103
281
  isDirectory(location) === false ||
104
- this.isCompilerOutputDirectory(location)
282
+ this.isCompilerOutputDirectory(location) ||
283
+ this.isProjectInputDirectory(location)
105
284
  ) {
106
285
  this.observedDirectories.delete(key);
107
286
  continue;
108
287
  }
109
288
  desired.set(key, location);
110
289
  }
290
+ if (process.platform === "win32") {
291
+ for (const [key, location] of desired) {
292
+ if (
293
+ [...desired].some(
294
+ ([candidateKey, candidate]) =>
295
+ candidateKey !== key && isPathWithin(candidate, location),
296
+ )
297
+ ) {
298
+ desired.delete(key);
299
+ }
300
+ }
301
+ }
111
302
  syncWatchers(
112
303
  this.directoryWatchers,
113
304
  desired,
114
305
  (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
- }),
306
+ fs.watch(
307
+ watcherRegistrationPath(location),
308
+ {
309
+ persistent: true,
310
+ recursive: process.platform === "win32",
311
+ },
312
+ (event, filename) => {
313
+ const changed =
314
+ filename === null
315
+ ? undefined
316
+ : path.resolve(location, filename.toString());
317
+ const pluginInput = changed ?? location;
318
+ if (this.isPluginInput(pluginInput)) {
319
+ this.callbacks.onInputChange({
320
+ kind: "plugin",
321
+ path: pluginInput,
322
+ });
323
+ return;
324
+ }
325
+ const plan = planCompilerDirectoryWatchEvent({
326
+ changed,
327
+ event,
328
+ exists: fs.existsSync,
329
+ location,
330
+ platform: process.platform,
331
+ trackedFiles: this.files,
332
+ });
333
+ this.rearmFileWatchers(plan.rearm);
334
+ for (const file of this.compilerChangesToReport(
335
+ plan.changes,
336
+ changed,
337
+ event,
338
+ )) {
339
+ this.callbacks.onInputChange({
340
+ kind: this.classifyCompilerInput(file),
341
+ path: file,
342
+ });
343
+ }
344
+ if (plan.refresh) this.refreshFromDirectory(location, changed);
345
+ },
346
+ ),
132
347
  (location, error) => this.callbacks.onError(location, error),
133
348
  );
134
349
  }
135
350
 
351
+ /**
352
+ * Narrow a plan's changes to the tracked files that actually moved.
353
+ *
354
+ * A backend that cannot name what changed forces the plan to nominate every
355
+ * tracked file under the watched directory, which is the only safe answer it
356
+ * can give from an event carrying no filename. macOS delivers such events for
357
+ * ordinary activity elsewhere in the project, so the compiler lane would wake
358
+ * for sources nobody touched. Only a content notification passes through: it
359
+ * is the one event that claims the bytes moved. A rename claims the directory
360
+ * entry was rewritten and an unnamed event claims nothing, so both are
361
+ * decided from the bytes, which is the question neither of them answered.
362
+ */
363
+ private compilerChangesToReport(
364
+ changes: readonly string[],
365
+ changed: string | undefined,
366
+ event: string,
367
+ ): string[] {
368
+ // A content notification is taken at its word: the backend is telling us
369
+ // these bytes changed, and second-guessing it would lose an edit that
370
+ // landed inside the clock's resolution. A rename says the directory entry
371
+ // was rewritten, which is a different claim — a file can be moved back, or
372
+ // replaced by an identical copy, without its content moving at all — and an
373
+ // event that cannot name anything makes no claim about content either.
374
+ // Those two are decided from the bytes, and the rearm they drive is
375
+ // unaffected, because rebinding is about the inode and not the content.
376
+ if (changed !== undefined && event !== "rename") {
377
+ for (const file of changes) this.recordCompilerFileStamp(file);
378
+ return [...changes];
379
+ }
380
+ return changes.filter((file) => this.compilerFileMoved(file));
381
+ }
382
+
383
+ private recordCompilerFileStamp(file: string): void {
384
+ this.compilerFileStamps.set(pathKey(file), compilerFileStamp(file));
385
+ }
386
+
387
+ private rearmFileWatchers(files: readonly string[]): void {
388
+ for (const file of files) {
389
+ const key = pathKey(file);
390
+ this.fileWatchers.get(key)?.close();
391
+ this.fileWatchers.delete(key);
392
+ }
393
+ this.syncFileWatchers();
394
+ }
395
+
136
396
  private syncExtraWatchers(): void {
137
397
  const directories = new Map<string, string>();
138
398
  for (const input of this.extraInputs) {
@@ -144,24 +404,474 @@ export class WatchTopology {
144
404
  this.extraWatchers,
145
405
  directories,
146
406
  (location) =>
147
- fs.watch(location, { persistent: true }, () => {
148
- this.callbacks.onInputChange();
149
- }),
407
+ fs.watch(
408
+ watcherRegistrationPath(location),
409
+ { persistent: true },
410
+ (_event, filename) => {
411
+ const changed =
412
+ filename === null
413
+ ? undefined
414
+ : path.resolve(location, filename.toString());
415
+ this.callbacks.onInputChange({
416
+ kind: "plugin",
417
+ path: changed ?? location,
418
+ });
419
+ },
420
+ ),
150
421
  (location, error) => this.callbacks.onError(location, error),
151
422
  );
152
423
  }
153
424
 
425
+ private syncProjectInputWatchers(): void {
426
+ if (this.closed) return;
427
+ const identities = createProjectInputPathIdentityContext();
428
+ const desired = new Map<string, string>();
429
+ for (const file of this.projectInputDeclarations("file")) {
430
+ if (this.isProjectInputCompilerOutput(file, identities)) continue;
431
+ const location = this.projectInputWatchRoot(
432
+ "file",
433
+ file,
434
+ path.dirname(file),
435
+ );
436
+ if (location !== undefined) {
437
+ this.retainProjectInputWatchRoot(desired, identities, location);
438
+ }
439
+ }
440
+ for (const glob of this.projectInputDeclarations("glob")) {
441
+ const root = literalGlobRoot(glob);
442
+ if (this.isProjectInputCompilerOutputDirectory(root, identities)) {
443
+ continue;
444
+ }
445
+ const location = this.projectInputWatchRoot("glob", glob, root);
446
+ if (location !== undefined) {
447
+ this.retainProjectInputWatchRoot(desired, identities, location);
448
+ }
449
+ }
450
+ for (const file of this.projectInputDeclarations("reload")) {
451
+ if (this.isProjectInputCompilerOutput(file, identities)) continue;
452
+ const location = this.projectInputWatchRoot(
453
+ "reload",
454
+ file,
455
+ path.dirname(file),
456
+ );
457
+ if (location !== undefined) {
458
+ this.retainProjectInputWatchRoot(desired, identities, location);
459
+ }
460
+ }
461
+ for (const directory of this.projectInputDeclarations("reload-directory")) {
462
+ if (this.isProjectInputCompilerOutputDirectory(directory, identities)) {
463
+ continue;
464
+ }
465
+ const location = this.projectInputWatchRoot(
466
+ "reload-directory",
467
+ directory,
468
+ directory,
469
+ );
470
+ if (location !== undefined) {
471
+ this.retainProjectInputWatchRoot(desired, identities, location);
472
+ }
473
+ }
474
+ const active = new Map<string, string>();
475
+ for (const location of projectInputActiveWatchDirectories(
476
+ desired.values(),
477
+ identities,
478
+ )) {
479
+ const identity = identities.resolve(location);
480
+ active.set(identity.key, identity.path);
481
+ }
482
+ syncWatchers(
483
+ this.projectInputWatchers,
484
+ active,
485
+ (location) =>
486
+ fs.watch(
487
+ watcherRegistrationPath(location),
488
+ { persistent: true, recursive: true },
489
+ (_event, filename) => {
490
+ const changed =
491
+ filename === null
492
+ ? undefined
493
+ : path.resolve(location, filename.toString());
494
+ this.refreshProjectInputs(location, changed);
495
+ },
496
+ ),
497
+ (location, error) => {
498
+ const key = identities.resolve(location).key;
499
+ const firstFailure = !this.projectInputRejectedWatchRoots.has(key);
500
+ this.projectInputRejectedWatchRoots.add(key);
501
+ this.callbacks.onError(location, error);
502
+ if (firstFailure) {
503
+ queueMicrotask(() => this.syncProjectInputWatchers());
504
+ }
505
+ },
506
+ );
507
+ this.syncProjectInputLinkWatchers(identities);
508
+ this.callbacks.onProjectInputWatchRoots?.(
509
+ [...this.projectInputWatchers.keys()].sort(),
510
+ );
511
+ }
512
+
513
+ /**
514
+ * Watch the directory that holds a declaration which is itself a link.
515
+ *
516
+ * A recursive watcher cannot report the link being replaced. The backend that
517
+ * keys its handles by path skips an entry it already knows, and the handle it
518
+ * put on the entry followed the link to the target's inode, which unlinking
519
+ * and recreating the link never touches. A plain directory watch has neither
520
+ * property: it reports the entry by name the moment it moves. These are kept
521
+ * apart from the recursive roots because they are not roots — they observe
522
+ * one directory, they are never reported as watch roots, and an ancestor
523
+ * covering them does not make them redundant.
524
+ */
525
+ private syncProjectInputLinkWatchers(
526
+ identities: ProjectInputPathIdentityContext,
527
+ ): void {
528
+ const desired = new Map<string, string>();
529
+ for (const kind of ["file", "reload"] as const) {
530
+ for (const declaration of this.projectInputDeclarations(kind)) {
531
+ const declared = path.resolve(declaration);
532
+ // The test is whether the declaration is itself a link, not whether its
533
+ // spelling is canonical. Comparing against the resolved identity would
534
+ // admit every declaration whose ancestor is aliased — which on macOS is
535
+ // every declaration under the system temporary directory — and it would
536
+ // still miss the retarget, because the watcher goes below the link.
537
+ if (!isSymbolicLink(declared)) continue;
538
+ if (this.isProjectInputCompilerOutput(declared, identities)) continue;
539
+ const parent = nearestExistingDirectory(path.dirname(declared));
540
+ if (parent === undefined) continue;
541
+ desired.set(identities.resolve(parent).key, parent);
542
+ }
543
+ }
544
+ syncWatchers(
545
+ this.projectInputLinkWatchers,
546
+ desired,
547
+ (location) =>
548
+ fs.watch(
549
+ watcherRegistrationPath(location),
550
+ { persistent: true },
551
+ (_event, filename) => {
552
+ const changed =
553
+ filename === null
554
+ ? undefined
555
+ : path.resolve(location, filename.toString());
556
+ this.refreshProjectInputs(location, changed);
557
+ },
558
+ ),
559
+ (location, error) => this.callbacks.onError(location, error),
560
+ );
561
+ }
562
+
563
+ /** Drop the watcher that just reported a directory replacement. */
564
+ private retireProjectInputWatcher(
565
+ location: string,
566
+ identities: ProjectInputPathIdentityContext,
567
+ ): void {
568
+ const key = identities.resolve(location).key;
569
+ // A plain directory watcher binds an inode, so a replacement strands it
570
+ // exactly as it strands a recursive root. Both maps are keyed the same way,
571
+ // so both are retired together and the next sync reinstalls whichever the
572
+ // declarations still call for.
573
+ for (const watchers of [
574
+ this.projectInputWatchers,
575
+ this.projectInputLinkWatchers,
576
+ ]) {
577
+ const watcher = watchers.get(key);
578
+ if (watcher === undefined) continue;
579
+ watcher.close();
580
+ watchers.delete(key);
581
+ }
582
+ }
583
+
584
+ private retainProjectInputWatchRoot(
585
+ desired: Map<string, string>,
586
+ identities: ProjectInputPathIdentityContext,
587
+ location: string,
588
+ ): void {
589
+ const available = projectInputAvailableWatchDirectory(
590
+ location,
591
+ this.projectInputRejectedWatchRoots,
592
+ identities,
593
+ this.projectInputs.root,
594
+ );
595
+ if (available === undefined) return;
596
+ const identity = identities.resolve(available);
597
+ desired.set(identity.key, identity.path);
598
+ }
599
+
600
+ /**
601
+ * One snapshot holding every spelling of every declaration.
602
+ *
603
+ * Consumers that decide from a population rather than from a single path have
604
+ * to see both, or half of them answer from the file a link pointed at when
605
+ * the snapshot was published while the event they are judging resolved to the
606
+ * file it points at now.
607
+ */
608
+ private projectInputPopulation(): ITtscProjectInputSnapshot {
609
+ return {
610
+ files: this.projectInputDeclarations("file"),
611
+ globs: this.projectInputDeclarations("glob"),
612
+ reloadDirectories: this.projectInputDeclarations("reload-directory"),
613
+ reloadFiles: this.projectInputDeclarations("reload"),
614
+ root: this.projectInputs.root,
615
+ };
616
+ }
617
+
618
+ /**
619
+ * Every spelling of one declaration that has to be anchored separately.
620
+ *
621
+ * The retained snapshot is normalized to physical identities, which is what
622
+ * every comparison needs but not what every watcher needs: a declaration
623
+ * reached through a symlink resolves to its target's directory, so anchoring
624
+ * the normalized form alone watches the bytes and never the link. Retargeting
625
+ * or replacing the link then goes unobserved, even though it is exactly what
626
+ * decides which bytes the declaration names next. Both spellings are planned
627
+ * through the same root selection, so the project-root hoist and the
628
+ * nearest-existing-ancestor boundary still bound each of them, and the active
629
+ * set drops one again whenever they coincide or share an ancestor.
630
+ */
631
+ private projectInputDeclarations(
632
+ kind: "file" | "glob" | "reload" | "reload-directory",
633
+ ): string[] {
634
+ const select = (snapshot: ITtscProjectInputSnapshot): readonly string[] =>
635
+ kind === "file"
636
+ ? snapshot.files
637
+ : kind === "glob"
638
+ ? snapshot.globs
639
+ : kind === "reload"
640
+ ? (snapshot.reloadFiles ?? [])
641
+ : (snapshot.reloadDirectories ?? []);
642
+ const seen = new Set<string>();
643
+ const declarations: string[] = [];
644
+ for (const entry of [
645
+ ...select(this.projectInputs),
646
+ ...select(this.declaredProjectInputs),
647
+ ]) {
648
+ const key = resolveProjectInputPath(entry);
649
+ if (seen.has(key)) continue;
650
+ seen.add(key);
651
+ declarations.push(entry);
652
+ }
653
+ return declarations;
654
+ }
655
+
656
+ private projectInputWatchRoot(
657
+ kind: "file" | "glob" | "reload" | "reload-directory",
658
+ declaration: string,
659
+ target: string,
660
+ ): string | undefined {
661
+ const key = projectInputDeclarationKey(kind, declaration);
662
+ const retained = this.projectInputWatchRoots.get(key);
663
+ if (retained !== undefined && isDirectory(retained)) return retained;
664
+ const resolved = projectInputRecursiveWatchRoot(
665
+ target,
666
+ this.projectInputs.root,
667
+ );
668
+ if (resolved !== undefined) this.projectInputWatchRoots.set(key, resolved);
669
+ return resolved;
670
+ }
671
+
672
+ private refreshProjectInputs(location: string, changed?: string): void {
673
+ try {
674
+ const previous = this.projectInputMatches;
675
+ const identities = createProjectInputPathIdentityContext();
676
+ // One population for the whole decision. Every question below is asked of
677
+ // the same declarations, and rebuilding it per question would both cost
678
+ // more and let two answers disagree about what was declared.
679
+ const population = this.projectInputPopulation();
680
+ const directlyMatched =
681
+ changed !== undefined &&
682
+ (previous.has(identities.resolve(changed).key) ||
683
+ matchesProjectInput(population, changed, identities));
684
+ const topologyMatched =
685
+ changed !== undefined &&
686
+ projectInputTopologyMayAffect(
687
+ population,
688
+ changed,
689
+ previous,
690
+ identities,
691
+ );
692
+ if (
693
+ changed !== undefined &&
694
+ (this.isProjectInputCompilerOutput(changed, identities) ||
695
+ (directlyMatched === false && topologyMatched === false))
696
+ ) {
697
+ return;
698
+ }
699
+ // Rearm before snapshotting. A watcher that has to be replaced stops
700
+ // delivering the moment it is closed, so a scan taken first would become
701
+ // the baseline for a window in which nothing was watched, and anything
702
+ // written there would never be announced again. Reinstalling first makes
703
+ // the scan below observe whatever the gap swallowed.
704
+ if (
705
+ changed !== undefined &&
706
+ projectInputReplacementStrandsWatchers(population, changed, identities)
707
+ ) {
708
+ this.retireProjectInputWatcher(location, identities);
709
+ this.syncProjectInputWatchers();
710
+ }
711
+ const next = this.collectProjectInputMatches();
712
+ const membershipChanged = mapsEqual(previous, next) === false;
713
+ const nextFingerprints =
714
+ changed === undefined ||
715
+ membershipChanged ||
716
+ directlyMatched ||
717
+ topologyMatched
718
+ ? fingerprintProjectInputMatches(next)
719
+ : this.projectInputFingerprints;
720
+ const contentChanged =
721
+ mapsEqual(this.projectInputFingerprints, nextFingerprints) === false;
722
+ const changedInputs = projectInputChangedPaths({
723
+ next,
724
+ nextFingerprints,
725
+ previous,
726
+ previousFingerprints: this.projectInputFingerprints,
727
+ });
728
+ // Both spellings classify the event. The normalized form names the file a
729
+ // link pointed at when the snapshot was published, so after a retarget it
730
+ // names the wrong one; only the declared form resolves to what the link
731
+ // points at now, which is the selection this lane exists to protect.
732
+ const reload = projectInputReloadEventShouldNotify({
733
+ changed,
734
+ changedInputs,
735
+ globs: population.globs,
736
+ reloadDirectories: population.reloadDirectories ?? [],
737
+ reloadFiles: population.reloadFiles ?? [],
738
+ });
739
+ const invalidate = projectInputMembershipInvalidatesProgram({
740
+ changed,
741
+ changedInputs,
742
+ contentChanged,
743
+ next,
744
+ previous,
745
+ });
746
+ this.projectInputMatches = next;
747
+ this.projectInputFingerprints = nextFingerprints;
748
+ this.syncProjectInputWatchers();
749
+ // A JSON/TS/JS project-input member can simultaneously enter or leave
750
+ // the compiler Program. Reconcile the compiler watch snapshot before
751
+ // scheduling its resident invalidation, so runWatch's post-cycle refresh
752
+ // does not rediscover the same delta as a broader execution reload.
753
+ if (invalidate) this.refresh(false);
754
+ if (
755
+ projectInputEventShouldNotify({
756
+ contentChanged,
757
+ directlyMatched,
758
+ membershipChanged,
759
+ }) &&
760
+ (changed === undefined ||
761
+ this.isProjectInputCompilerOutput(changed, identities) === false)
762
+ ) {
763
+ this.callbacks.onInputChange(
764
+ reload
765
+ ? { kind: "config", path: changed }
766
+ : {
767
+ ...(invalidate ? { invalidate: true } : {}),
768
+ kind: "project",
769
+ path: changed,
770
+ },
771
+ );
772
+ }
773
+ } catch (error) {
774
+ // A rename can invalidate the old filesystem object before the
775
+ // replacement is readable. Rebind ancestor ownership even when the
776
+ // population scan races that transient gap, so a later create cannot be
777
+ // stranded without a watcher.
778
+ this.syncProjectInputWatchers();
779
+ this.callbacks.onError(location, error);
780
+ }
781
+ }
782
+
783
+ private collectProjectInputMatches(): Map<string, string> {
784
+ const identities = createProjectInputPathIdentityContext();
785
+ const matches = new Map<string, string>();
786
+ // Both spellings are scanned, and each is resolved here rather than when
787
+ // the snapshot arrived. A declaration reached through a symlink otherwise
788
+ // keeps the identity it had when it was published, so retargeting the link
789
+ // moves no key, changes no fingerprint, and the cycle never learns that the
790
+ // bytes it depends on are now a different file.
791
+ for (const file of this.projectInputDeclarations("file")) {
792
+ if (
793
+ fs.existsSync(file) &&
794
+ this.isProjectInputCompilerOutput(file, identities) === false
795
+ ) {
796
+ const identity = identities.resolve(file);
797
+ matches.set(identity.key, identity.path);
798
+ }
799
+ }
800
+ for (const file of this.projectInputDeclarations("reload")) {
801
+ if (
802
+ fs.existsSync(file) &&
803
+ this.isProjectInputCompilerOutput(file, identities) === false
804
+ ) {
805
+ const identity = identities.resolve(file);
806
+ matches.set(identity.key, identity.path);
807
+ }
808
+ }
809
+ for (const directory of this.projectInputDeclarations("reload-directory")) {
810
+ if (
811
+ isDirectory(directory) &&
812
+ this.isProjectInputCompilerOutputDirectory(directory, identities) ===
813
+ false
814
+ ) {
815
+ const identity = identities.resolve(directory);
816
+ matches.set(identity.key, identity.path);
817
+ }
818
+ }
819
+ for (const glob of this.projectInputDeclarations("glob")) {
820
+ const root = literalGlobRoot(glob);
821
+ if (
822
+ isDirectory(root) === false ||
823
+ this.isProjectInputCompilerOutputDirectory(root, identities)
824
+ ) {
825
+ continue;
826
+ }
827
+ const stack = [root];
828
+ while (stack.length !== 0) {
829
+ const current = stack.pop()!;
830
+ let entries: fs.Dirent[];
831
+ try {
832
+ entries = fs.readdirSync(current, { withFileTypes: true });
833
+ } catch (error) {
834
+ if (isVanishedFilesystemEntry(error)) continue;
835
+ throw error;
836
+ }
837
+ for (const entry of entries) {
838
+ const location = path.join(current, entry.name);
839
+ if (this.isProjectInputCompilerOutput(location, identities)) {
840
+ continue;
841
+ }
842
+ if (entry.isDirectory()) {
843
+ stack.push(location);
844
+ } else if (
845
+ entry.isFile() &&
846
+ matchesProjectInputGlob(glob, location, identities)
847
+ ) {
848
+ const identity = identities.resolve(location);
849
+ matches.set(identity.key, identity.path);
850
+ }
851
+ }
852
+ }
853
+ }
854
+ return matches;
855
+ }
856
+
154
857
  private refreshFromDirectory(location: string, changed?: string): void {
155
858
  if (
156
859
  changed !== undefined &&
157
860
  isDirectory(changed) &&
158
- this.isCompilerOutputDirectory(changed) === false
861
+ this.isCompilerOutputDirectory(changed) === false &&
862
+ this.isProjectInputDirectory(changed) === false
159
863
  ) {
160
864
  this.observedDirectories.set(pathKey(changed), changed);
161
865
  }
162
866
  try {
163
867
  this.refresh(true);
164
868
  } catch (error) {
869
+ for (const reload of reloadInputsForFailedTopologyRefresh(
870
+ this.reloadFiles.values(),
871
+ changed,
872
+ )) {
873
+ this.callbacks.onInputChange({ kind: "config", path: reload });
874
+ }
165
875
  this.callbacks.onError(location, error);
166
876
  }
167
877
  }
@@ -171,14 +881,117 @@ export class WatchTopology {
171
881
  isPathWithin(output, location),
172
882
  );
173
883
  }
884
+
885
+ private isCompilerOutput(location: string): boolean {
886
+ return (
887
+ this.outputFiles.has(pathKey(location)) ||
888
+ this.isCompilerOutputDirectory(location)
889
+ );
890
+ }
891
+
892
+ private isProjectInputCompilerOutputDirectory(
893
+ location: string,
894
+ identities: ProjectInputPathIdentityContext,
895
+ ): boolean {
896
+ const root = this.projectInputs.root;
897
+ return [...this.outputs.values()].some((output) => {
898
+ // An output directory that is the project itself, or holds it, is not a
899
+ // place where only build products live -- the sources are there too. A
900
+ // project emitting in place declares exactly that, and honouring it
901
+ // literally would classify every declared input as a product and leave
902
+ // the whole lane unwatched without anything failing to say so. Watching
903
+ // is the safe side here: a product that gets watched costs an extra
904
+ // rebuild, while an input that does not is never seen again.
905
+ if (root !== "" && identities.isWithin(output, root)) return false;
906
+ return identities.isWithin(output, location);
907
+ });
908
+ }
909
+
910
+ private isProjectInputCompilerOutput(
911
+ location: string,
912
+ identities: ProjectInputPathIdentityContext,
913
+ ): boolean {
914
+ const key = identities.resolve(location).key;
915
+ return (
916
+ [...this.outputFiles.values()].some(
917
+ (output) => identities.resolve(output).key === key,
918
+ ) || this.isProjectInputCompilerOutputDirectory(location, identities)
919
+ );
920
+ }
921
+
922
+ private isProjectInputDirectory(location: string): boolean {
923
+ const resolved = path.resolve(location);
924
+ const identities = createProjectInputPathIdentityContext();
925
+ return (
926
+ this.projectInputs.files.some(
927
+ (file) =>
928
+ identities.isWithin(resolved, file) ||
929
+ identities.isWithin(path.dirname(file), resolved),
930
+ ) ||
931
+ (this.projectInputs.reloadFiles ?? []).some(
932
+ (file) =>
933
+ identities.isWithin(resolved, file) ||
934
+ identities.isWithin(path.dirname(file), resolved),
935
+ ) ||
936
+ (this.projectInputs.reloadDirectories ?? []).some(
937
+ (directory) =>
938
+ identities.isWithin(resolved, directory) ||
939
+ identities.isWithin(directory, resolved),
940
+ ) ||
941
+ this.projectInputs.globs.some((glob) => {
942
+ const root = literalGlobRoot(glob);
943
+ return (
944
+ identities.isWithin(root, resolved) ||
945
+ identities.isWithin(resolved, root)
946
+ );
947
+ })
948
+ );
949
+ }
950
+
951
+ private classifyCompilerInput(
952
+ location: string,
953
+ ): "compiler" | "config" | "plugin" {
954
+ if (this.isPluginInput(location)) return "plugin";
955
+ return this.reloadFiles.has(pathKey(location)) ? "config" : "compiler";
956
+ }
957
+
958
+ private isPluginInput(location: string): boolean {
959
+ const resolved = path.resolve(location);
960
+ return this.extraInputs.some(
961
+ (input) =>
962
+ pathKey(input) === pathKey(resolved) || isPathWithin(input, resolved),
963
+ );
964
+ }
965
+ }
966
+
967
+ export function reloadInputsForFailedTopologyRefresh(
968
+ reloadFiles: Iterable<string>,
969
+ changed?: string,
970
+ ): string[] {
971
+ const changedKey = changed === undefined ? undefined : pathKey(changed);
972
+ const reloads = new Map<string, string>();
973
+ for (const location of reloadFiles) {
974
+ const resolved = path.resolve(location);
975
+ const key = pathKey(resolved);
976
+ if (
977
+ (changedKey !== undefined && key === changedKey) ||
978
+ fs.existsSync(resolved) === false
979
+ ) {
980
+ reloads.set(key, resolved);
981
+ }
982
+ }
983
+ return [...reloads.values()].sort();
174
984
  }
175
985
 
176
986
  function resolveWatchTopology(
177
987
  options: WatchTopologyOptions,
178
988
  extraInputs: readonly string[],
179
989
  ): ResolvedWatchTopology {
990
+ let analysisOnly = options.emit === false;
180
991
  const files = new Map<string, string>();
992
+ const outputFiles = new Map<string, string>();
181
993
  const outputs = new Map<string, string>();
994
+ const reloadFiles = new Map<string, string>();
182
995
  const roots: string[] = [];
183
996
  if (options.files.length !== 0) {
184
997
  const project = readProjectConfig({
@@ -186,29 +999,70 @@ function resolveWatchTopology(
186
999
  projectRoot: options.projectRoot,
187
1000
  tsconfig: options.tsconfig,
188
1001
  });
1002
+ analysisOnly = watchTopologyAnalysisOnly(options, project);
189
1003
  roots.push(project.root);
190
1004
  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)),
1005
+ addPaths(reloadFiles, project.configPaths);
1006
+ const positionalInputs = options.files.map((file) =>
1007
+ path.resolve(options.cwd, file),
195
1008
  );
1009
+ if (
1010
+ positionalInputs.length === 1 &&
1011
+ (options.emit ?? project.compilerOptions.noEmit !== true)
1012
+ ) {
1013
+ addPaths(outputFiles, [
1014
+ resolveSingleFileOutput({
1015
+ cliOutDir: options.outDir,
1016
+ cwd: options.cwd,
1017
+ file: positionalInputs[0]!,
1018
+ passthrough: options.passthrough,
1019
+ tsconfig: options.tsconfig,
1020
+ }),
1021
+ ]);
1022
+ }
1023
+ addPaths(files, positionalInputs);
196
1024
  } else {
197
- for (const project of readReferencedProjects(options)) {
1025
+ const projects = readReferencedProjects(options);
1026
+ if (projects[0] !== undefined) {
1027
+ analysisOnly = watchTopologyAnalysisOnly(options, projects[0]);
1028
+ }
1029
+ for (const project of projects) {
198
1030
  roots.push(project.root);
199
1031
  addPaths(files, project.configPaths);
200
- addPaths(outputs, resolveCompilerOutputs(project, options).directories);
201
- addPaths(files, listCompilerInputs(project, options));
1032
+ addPaths(reloadFiles, project.configPaths);
1033
+ const compilerInputs = listCompilerInputs(project, options);
1034
+ const compilerOutputs = resolveCompilerOutputs(project, options);
1035
+ addPaths(outputFiles, compilerOutputs.files);
1036
+ addPaths(
1037
+ outputFiles,
1038
+ inferAdjacentCompilerOutputs(project, options, compilerInputs),
1039
+ );
1040
+ addPaths(outputs, compilerOutputs.directories);
1041
+ addPaths(files, compilerInputs);
202
1042
  }
203
1043
  }
204
1044
  addPaths(files, extraInputs);
205
1045
  return {
1046
+ analysisOnly,
206
1047
  directories: collectTopologyDirectories(files.values(), roots),
207
1048
  files,
1049
+ outputFiles,
208
1050
  outputs,
1051
+ reloadFiles,
209
1052
  };
210
1053
  }
211
1054
 
1055
+ function watchTopologyAnalysisOnly(
1056
+ options: WatchTopologyOptions,
1057
+ project: ITtscParsedProjectConfig,
1058
+ ): boolean {
1059
+ if (options.emit !== undefined) return options.emit === false;
1060
+ const noEmit =
1061
+ passthroughBooleanOption(options.passthrough, "--noEmit") ??
1062
+ project.compilerOptions.noEmit === true;
1063
+ return noEmit;
1064
+ }
1065
+
212
1066
  function readReferencedProjects(
213
1067
  options: WatchTopologyOptions,
214
1068
  ): ITtscParsedProjectConfig[] {
@@ -309,20 +1163,301 @@ function resolveCompilerOutputs(
309
1163
  project: ITtscParsedProjectConfig,
310
1164
  options: WatchTopologyOptions,
311
1165
  ): { directories: string[]; files: string[] } {
1166
+ const emit = effectiveCompilerEmit(project, options);
1167
+ const directories = new Set<string>();
1168
+ const files = new Set<string>();
1169
+ if (
1170
+ emit.outDir !== undefined &&
1171
+ (emit.javascript || (emit.declaration && emit.declarationDir === undefined))
1172
+ ) {
1173
+ directories.add(emit.outDir);
1174
+ }
1175
+ if (emit.declaration && emit.declarationDir !== undefined) {
1176
+ directories.add(emit.declarationDir);
1177
+ }
1178
+ if (emit.outFile !== undefined) {
1179
+ if (emit.javascript) {
1180
+ files.add(emit.outFile);
1181
+ if (emit.sourceMap) files.add(`${emit.outFile}.map`);
1182
+ }
1183
+ if (emit.declaration) {
1184
+ const declaration = replaceOutputExtension(emit.outFile, ".d.ts");
1185
+ files.add(declaration);
1186
+ if (emit.declarationMap) files.add(`${declaration}.map`);
1187
+ }
1188
+ }
1189
+ if (emit.incremental) {
1190
+ files.add(defaultTsBuildInfoFile(project, emit));
1191
+ }
1192
+ return {
1193
+ directories: [...directories],
1194
+ files: [...files],
1195
+ };
1196
+ }
1197
+
1198
+ function inferAdjacentCompilerOutputs(
1199
+ project: ITtscParsedProjectConfig,
1200
+ options: WatchTopologyOptions,
1201
+ inputs: readonly string[],
1202
+ ): string[] {
1203
+ const emit = effectiveCompilerEmit(project, options);
1204
+ const outputs = new Set<string>();
1205
+ for (const input of inputs) {
1206
+ const extension = path.extname(input).toLowerCase();
1207
+ if (!isCompilerEmittableSourceExtension(extension)) {
1208
+ continue;
1209
+ }
1210
+ if (/\.d\.(?:ts|mts|cts)$/i.test(input)) continue;
1211
+ const stem = input.slice(0, -extension.length);
1212
+ if (
1213
+ emit.javascript &&
1214
+ emit.outDir === undefined &&
1215
+ emit.outFile === undefined &&
1216
+ (!isJavaScriptSourceExtension(extension) ||
1217
+ (extension === ".jsx" && emit.jsx !== "preserve"))
1218
+ ) {
1219
+ const javascriptExtension =
1220
+ extension === ".mts"
1221
+ ? ".mjs"
1222
+ : extension === ".cts"
1223
+ ? ".cjs"
1224
+ : extension === ".tsx" && emit.jsx === "preserve"
1225
+ ? ".jsx"
1226
+ : ".js";
1227
+ const javascript = stem + javascriptExtension;
1228
+ outputs.add(javascript);
1229
+ if (emit.sourceMap) outputs.add(`${javascript}.map`);
1230
+ }
1231
+ if (
1232
+ emit.declaration &&
1233
+ emit.outDir === undefined &&
1234
+ emit.declarationDir === undefined &&
1235
+ emit.outFile === undefined
1236
+ ) {
1237
+ const declarationExtension =
1238
+ extension === ".mts" || extension === ".mjs"
1239
+ ? ".d.mts"
1240
+ : extension === ".cts" || extension === ".cjs"
1241
+ ? ".d.cts"
1242
+ : ".d.ts";
1243
+ const declaration = stem + declarationExtension;
1244
+ outputs.add(declaration);
1245
+ if (emit.declarationMap) outputs.add(`${declaration}.map`);
1246
+ }
1247
+ }
1248
+ return [...outputs];
1249
+ }
1250
+
1251
+ function isCompilerEmittableSourceExtension(extension: string): boolean {
1252
+ return [
1253
+ ".cjs",
1254
+ ".cts",
1255
+ ".js",
1256
+ ".jsx",
1257
+ ".mjs",
1258
+ ".mts",
1259
+ ".ts",
1260
+ ".tsx",
1261
+ ].includes(extension);
1262
+ }
1263
+
1264
+ function isJavaScriptSourceExtension(extension: string): boolean {
1265
+ return [".cjs", ".js", ".jsx", ".mjs"].includes(extension);
1266
+ }
1267
+
1268
+ type EffectiveCompilerEmit = {
1269
+ declaration: boolean;
1270
+ declarationDir?: string;
1271
+ declarationMap: boolean;
1272
+ incremental: boolean;
1273
+ javascript: boolean;
1274
+ jsx?: unknown;
1275
+ outDir?: string;
1276
+ outFile?: string;
1277
+ rootDir?: string;
1278
+ sourceMap: boolean;
1279
+ tsBuildInfoFile?: string;
1280
+ };
1281
+
1282
+ function effectiveCompilerEmit(
1283
+ project: ITtscParsedProjectConfig,
1284
+ options: WatchTopologyOptions,
1285
+ ): EffectiveCompilerEmit {
312
1286
  const compilerOptions = project.compilerOptions;
313
- const outDir = options.outDir
314
- ? path.resolve(options.cwd, options.outDir)
315
- : compilerOptions.outDir;
1287
+ const passthrough = options.passthrough;
1288
+ const noEmit =
1289
+ passthroughBooleanOption(passthrough, "--noEmit") ??
1290
+ (options.emit === false
1291
+ ? true
1292
+ : options.emit === true
1293
+ ? false
1294
+ : compilerOptions.noEmit === true);
1295
+ const composite =
1296
+ passthroughBooleanOption(passthrough, "--composite") ??
1297
+ compilerOptions.composite === true;
1298
+ const incremental =
1299
+ composite ||
1300
+ (passthroughBooleanOption(passthrough, "--incremental") ??
1301
+ compilerOptions.incremental === true);
1302
+ const emitDeclarationOnly =
1303
+ passthroughBooleanOption(passthrough, "--emitDeclarationOnly") ??
1304
+ (options.emit === true
1305
+ ? false
1306
+ : compilerOptions.emitDeclarationOnly === true);
1307
+ const declaration =
1308
+ !noEmit &&
1309
+ (composite ||
1310
+ (passthroughBooleanOption(passthrough, "--declaration") ??
1311
+ compilerOptions.declaration === true));
1312
+ const javascript = !noEmit && !emitDeclarationOnly;
1313
+ const sourceMap =
1314
+ javascript &&
1315
+ (passthroughBooleanOption(passthrough, "--sourceMap") ??
1316
+ compilerOptions.sourceMap === true) &&
1317
+ !(
1318
+ passthroughBooleanOption(passthrough, "--inlineSourceMap") ??
1319
+ compilerOptions.inlineSourceMap === true
1320
+ );
1321
+ const declarationMap =
1322
+ declaration &&
1323
+ (passthroughBooleanOption(passthrough, "--declarationMap") ??
1324
+ compilerOptions.declarationMap === true);
1325
+ const cliOutDir = passthroughPathOption(passthrough, "--outDir");
1326
+ const cliDeclarationDir = passthroughPathOption(
1327
+ passthrough,
1328
+ "--declarationDir",
1329
+ );
1330
+ const cliOutFile = passthroughPathOption(passthrough, "--outFile");
1331
+ const cliRootDir = passthroughPathOption(passthrough, "--rootDir");
1332
+ const cliTsBuildInfoFile = passthroughPathOption(
1333
+ passthrough,
1334
+ "--tsBuildInfoFile",
1335
+ );
1336
+ const jsx =
1337
+ passthroughStringOption(passthrough, "--jsx") ?? compilerOptions.jsx;
316
1338
  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)),
1339
+ declaration,
1340
+ declarationDir:
1341
+ cliDeclarationDir === null
1342
+ ? undefined
1343
+ : cliDeclarationDir !== undefined
1344
+ ? path.resolve(options.cwd, cliDeclarationDir)
1345
+ : typeof compilerOptions.declarationDir === "string"
1346
+ ? path.resolve(compilerOptions.declarationDir)
1347
+ : undefined,
1348
+ declarationMap,
1349
+ incremental,
1350
+ javascript,
1351
+ outDir:
1352
+ cliOutDir === null
1353
+ ? undefined
1354
+ : cliOutDir !== undefined
1355
+ ? path.resolve(options.cwd, cliOutDir)
1356
+ : options.outDir !== undefined
1357
+ ? path.resolve(options.cwd, options.outDir)
1358
+ : typeof compilerOptions.outDir === "string"
1359
+ ? path.resolve(compilerOptions.outDir)
1360
+ : undefined,
1361
+ outFile:
1362
+ cliOutFile === null
1363
+ ? undefined
1364
+ : cliOutFile !== undefined
1365
+ ? path.resolve(options.cwd, cliOutFile)
1366
+ : typeof compilerOptions.outFile === "string"
1367
+ ? path.resolve(compilerOptions.outFile)
1368
+ : undefined,
1369
+ rootDir:
1370
+ cliRootDir === null
1371
+ ? undefined
1372
+ : cliRootDir !== undefined
1373
+ ? path.resolve(options.cwd, cliRootDir)
1374
+ : typeof compilerOptions.rootDir === "string"
1375
+ ? path.resolve(compilerOptions.rootDir)
1376
+ : undefined,
1377
+ sourceMap,
1378
+ tsBuildInfoFile:
1379
+ cliTsBuildInfoFile === null
1380
+ ? undefined
1381
+ : cliTsBuildInfoFile !== undefined
1382
+ ? path.resolve(options.cwd, cliTsBuildInfoFile)
1383
+ : typeof compilerOptions.tsBuildInfoFile === "string"
1384
+ ? path.resolve(compilerOptions.tsBuildInfoFile)
1385
+ : undefined,
1386
+ jsx,
323
1387
  };
324
1388
  }
325
1389
 
1390
+ function defaultTsBuildInfoFile(
1391
+ project: ITtscParsedProjectConfig,
1392
+ emit: EffectiveCompilerEmit,
1393
+ ): string {
1394
+ if (emit.tsBuildInfoFile !== undefined) return emit.tsBuildInfoFile;
1395
+ const configWithoutExtension = replaceOutputExtension(project.path, "");
1396
+ if (emit.outDir === undefined) return `${configWithoutExtension}.tsbuildinfo`;
1397
+ const relative =
1398
+ emit.rootDir === undefined
1399
+ ? path.basename(configWithoutExtension)
1400
+ : path.relative(emit.rootDir, configWithoutExtension);
1401
+ return path.resolve(emit.outDir, `${relative}.tsbuildinfo`);
1402
+ }
1403
+
1404
+ function replaceOutputExtension(location: string, extension: string): string {
1405
+ const current = path.extname(location);
1406
+ return current === ""
1407
+ ? `${location}${extension}`
1408
+ : `${location.slice(0, -current.length)}${extension}`;
1409
+ }
1410
+
1411
+ function passthroughBooleanOption(
1412
+ tokens: readonly string[] | undefined,
1413
+ name: string,
1414
+ ): boolean | undefined {
1415
+ let value: boolean | undefined;
1416
+ for (let index = 0; index < (tokens?.length ?? 0); index++) {
1417
+ const token = tokens?.[index];
1418
+ if (token === undefined) continue;
1419
+ if (!passthroughOptionMatches(token, name)) continue;
1420
+ const next = tokens?.[index + 1];
1421
+ if (next === "true" || next === "false" || next === "null") {
1422
+ value = next === "true";
1423
+ index++;
1424
+ } else {
1425
+ value = true;
1426
+ }
1427
+ }
1428
+ return value;
1429
+ }
1430
+
1431
+ function passthroughPathOption(
1432
+ tokens: readonly string[] | undefined,
1433
+ name: string,
1434
+ ): string | null | undefined {
1435
+ let value: string | null | undefined;
1436
+ for (let index = 0; index < (tokens?.length ?? 0); index++) {
1437
+ const token = tokens?.[index];
1438
+ if (token === undefined) continue;
1439
+ if (!passthroughOptionMatches(token, name)) continue;
1440
+ if (index + 1 < (tokens?.length ?? 0)) {
1441
+ const next = tokens?.[++index];
1442
+ value = next === "null" ? null : next;
1443
+ }
1444
+ }
1445
+ return value;
1446
+ }
1447
+
1448
+ function passthroughStringOption(
1449
+ tokens: readonly string[] | undefined,
1450
+ name: string,
1451
+ ): string | undefined {
1452
+ return passthroughPathOption(tokens, name)?.toLowerCase() ?? undefined;
1453
+ }
1454
+
1455
+ function passthroughOptionMatches(token: string, name: string): boolean {
1456
+ if (!token.startsWith("-")) return false;
1457
+ if (token.includes("=")) return false;
1458
+ return resolveFlagSpec(token)?.name === resolveFlagSpec(name)?.name;
1459
+ }
1460
+
326
1461
  function isCompilerOutput(
327
1462
  file: string,
328
1463
  outputs: { directories: readonly string[]; files: readonly string[] },
@@ -367,7 +1502,14 @@ function collectInputDirectories(input: string): string[] {
367
1502
  while (stack.length !== 0) {
368
1503
  const current = stack.pop()!;
369
1504
  directories.push(current);
370
- for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
1505
+ let entries: fs.Dirent[];
1506
+ try {
1507
+ entries = fs.readdirSync(current, { withFileTypes: true });
1508
+ } catch (error) {
1509
+ if (isVanishedFilesystemEntry(error)) continue;
1510
+ throw error;
1511
+ }
1512
+ for (const entry of entries) {
371
1513
  if (entry.isDirectory()) {
372
1514
  stack.push(path.join(current, entry.name));
373
1515
  }
@@ -376,6 +1518,14 @@ function collectInputDirectories(input: string): string[] {
376
1518
  return directories;
377
1519
  }
378
1520
 
1521
+ function isVanishedFilesystemEntry(error: unknown): boolean {
1522
+ return (
1523
+ error instanceof Error &&
1524
+ "code" in error &&
1525
+ (error.code === "ENOENT" || error.code === "ENOTDIR")
1526
+ );
1527
+ }
1528
+
379
1529
  function isDirectory(location: string): boolean {
380
1530
  try {
381
1531
  return fs.statSync(location).isDirectory();
@@ -384,27 +1534,42 @@ function isDirectory(location: string): boolean {
384
1534
  }
385
1535
  }
386
1536
 
387
- function syncWatchers(
388
- watchers: Map<string, fs.FSWatcher>,
1537
+ type SynchronizedWatcher = {
1538
+ close(): void;
1539
+ on(event: "error", listener: (error: Error) => void): unknown;
1540
+ };
1541
+
1542
+ export function syncWatchers<T extends SynchronizedWatcher>(
1543
+ watchers: Map<string, T>,
389
1544
  desired: ReadonlyMap<string, string>,
390
- create: (location: string) => fs.FSWatcher,
1545
+ create: (location: string, key: string) => T,
391
1546
  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
- }
1547
+ ): boolean {
1548
+ let complete = true;
398
1549
  for (const [key, location] of desired) {
399
1550
  if (watchers.has(key)) continue;
400
1551
  try {
401
- const watcher = create(location);
402
- watcher.on("error", (error) => onError(location, error));
1552
+ const watcher = create(location, key);
1553
+ watcher.on("error", (error) => {
1554
+ if (watchers.get(key) === watcher) {
1555
+ watchers.delete(key);
1556
+ }
1557
+ watcher.close();
1558
+ onError(location, error);
1559
+ });
403
1560
  watchers.set(key, watcher);
404
1561
  } catch (error) {
1562
+ complete = false;
405
1563
  onError(location, error);
406
1564
  }
407
1565
  }
1566
+ if (!complete) return false;
1567
+ for (const [key, watcher] of watchers) {
1568
+ if (desired.has(key)) continue;
1569
+ watcher.close();
1570
+ watchers.delete(key);
1571
+ }
1572
+ return true;
408
1573
  }
409
1574
 
410
1575
  function closeWatchers(watchers: Map<string, fs.FSWatcher>): void {
@@ -429,6 +1594,708 @@ function uniqueExistingPaths(paths: readonly string[]): string[] {
429
1594
  return [...unique.values()];
430
1595
  }
431
1596
 
1597
+ function normalizeProjectInputSnapshot(
1598
+ snapshot: ITtscProjectInputSnapshot,
1599
+ ): ITtscProjectInputSnapshot {
1600
+ const identities = createProjectInputPathIdentityContext();
1601
+ const files = new Map<string, string>();
1602
+ const globs = new Map<string, string>();
1603
+ const reloadDirectories = new Map<string, string>();
1604
+ const reloadFiles = new Map<string, string>();
1605
+ for (const file of snapshot.files) {
1606
+ const identity = identities.resolve(file);
1607
+ files.set(identity.key, identity.path);
1608
+ }
1609
+ for (const glob of snapshot.globs) {
1610
+ const identity = identities.resolve(glob);
1611
+ globs.set(identity.key, identity.path.split(path.sep).join("/"));
1612
+ }
1613
+ for (const file of snapshot.reloadFiles ?? []) {
1614
+ const identity = identities.resolve(file);
1615
+ reloadFiles.set(identity.key, identity.path);
1616
+ }
1617
+ for (const directory of snapshot.reloadDirectories ?? []) {
1618
+ const identity = identities.resolve(directory);
1619
+ reloadDirectories.set(identity.key, identity.path);
1620
+ }
1621
+ return {
1622
+ files: [...files.values()].sort(),
1623
+ globs: [...globs.values()].sort(),
1624
+ reloadDirectories: [...reloadDirectories.values()].sort(),
1625
+ reloadFiles: [...reloadFiles.values()].sort(),
1626
+ root: identities.resolve(snapshot.root).path,
1627
+ };
1628
+ }
1629
+
1630
+ function projectInputSnapshotsEqual(
1631
+ left: ITtscProjectInputSnapshot,
1632
+ right: ITtscProjectInputSnapshot,
1633
+ ): boolean {
1634
+ const identities = createProjectInputPathIdentityContext();
1635
+ return (
1636
+ // Every sibling comparison in this class decides on identity, and this one
1637
+ // is only safe today because both operands come from the same normalizer.
1638
+ identities.resolve(left.root || ".").key ===
1639
+ identities.resolve(right.root || ".").key &&
1640
+ arraysEqual(left.files, right.files) &&
1641
+ arraysEqual(left.globs, right.globs) &&
1642
+ arraysEqual(left.reloadDirectories ?? [], right.reloadDirectories ?? []) &&
1643
+ arraysEqual(left.reloadFiles ?? [], right.reloadFiles ?? [])
1644
+ );
1645
+ }
1646
+
1647
+ export function literalGlobRoot(pattern: string): string {
1648
+ const resolved = path.resolve(pattern);
1649
+ const normalized = resolved.split("\\").join("/");
1650
+ const wildcard = normalized.search(/[*?]/);
1651
+ if (wildcard === -1) return path.dirname(resolved);
1652
+ const separator = normalized.lastIndexOf("/", wildcard);
1653
+ const prefix = separator < 0 ? "." : normalized.slice(0, separator);
1654
+ const volumeRoot = path.parse(resolved).root;
1655
+ const normalizedVolumeRoot = volumeRoot.split("\\").join("/");
1656
+ if (
1657
+ prefix.length === 0 ||
1658
+ prefix === normalizedVolumeRoot.replace(/\/$/, "")
1659
+ ) {
1660
+ return volumeRoot;
1661
+ }
1662
+ return path.resolve(prefix);
1663
+ }
1664
+
1665
+ function projectInputDeclarationKey(
1666
+ kind: "file" | "glob" | "reload" | "reload-directory",
1667
+ declaration: string,
1668
+ ): string {
1669
+ return `${kind}\0${resolveProjectInputPath(declaration)}`;
1670
+ }
1671
+
1672
+ /**
1673
+ * Chooses the one stable recursive watcher root owned by a project-input
1674
+ * declaration.
1675
+ *
1676
+ * Inputs inside the project share its physical root so directory replacement
1677
+ * cannot strand a child handle. External inputs use the nearest existing
1678
+ * ancestor of their declared parent, which is the explicit boundary for
1679
+ * observing a currently missing external tree without polling every file --
1680
+ * except that the boundary never rises to a directory holding the project,
1681
+ * since such a root outranks the project's own in the active merge and leaves
1682
+ * one handle over a shared system directory to carry everything. An external
1683
+ * declaration that can only be owned that way falls back to its own tree, and
1684
+ * is left unwatched when even that would contain the project.
1685
+ */
1686
+ export function projectInputWatchDirectories(
1687
+ target: string,
1688
+ projectRoot: string,
1689
+ ): string[] {
1690
+ const root = projectInputRecursiveWatchRoot(target, projectRoot);
1691
+ return root === undefined ? [] : [root];
1692
+ }
1693
+
1694
+ /**
1695
+ * Removes recursive roots already covered by an ancestor without rewriting the
1696
+ * declaration-specific roots retained by WatchTopology.
1697
+ */
1698
+ export function projectInputActiveWatchDirectories(
1699
+ directories: Iterable<string>,
1700
+ identities = createProjectInputPathIdentityContext(),
1701
+ ): string[] {
1702
+ const unique = new Map<string, string>();
1703
+ for (const directory of directories) {
1704
+ // Coverage is decided on physical identity so two spellings of one
1705
+ // directory cannot both survive, but the surviving entry keeps the
1706
+ // caller's spelling: this function selects roots, it does not rename them.
1707
+ const identity = identities.resolve(directory);
1708
+ if (!unique.has(identity.key)) unique.set(identity.key, directory);
1709
+ }
1710
+ return [...unique]
1711
+ .filter(([key]) => {
1712
+ let ancestor = path.dirname(key);
1713
+ while (ancestor !== key) {
1714
+ if (unique.has(ancestor)) return false;
1715
+ const parent = path.dirname(ancestor);
1716
+ if (parent === ancestor) break;
1717
+ ancestor = parent;
1718
+ }
1719
+ return true;
1720
+ })
1721
+ .map(([, directory]) => directory);
1722
+ }
1723
+
1724
+ export function projectInputAvailableWatchDirectory(
1725
+ location: string,
1726
+ rejected: ReadonlySet<string>,
1727
+ identities: ProjectInputPathIdentityContext = createProjectInputPathIdentityContext(),
1728
+ projectRoot?: string,
1729
+ ): string | undefined {
1730
+ const resolvedProjectRoot =
1731
+ projectRoot === undefined ? undefined : path.resolve(projectRoot);
1732
+ let current = path.resolve(location);
1733
+ while (true) {
1734
+ const identity = identities.resolve(current);
1735
+ // Escalation obeys the same ceiling root selection does. A watcher that
1736
+ // failed once would otherwise be replaced by one over a directory holding
1737
+ // the project, which outranks the project's own root in the active merge
1738
+ // and re-opens, through the recovery path, the exact swallow that selection
1739
+ // refuses to create. Giving up the failed root is the lesser loss.
1740
+ if (
1741
+ resolvedProjectRoot !== undefined &&
1742
+ identity.key !== identities.resolve(resolvedProjectRoot).key &&
1743
+ identities.isWithin(identity.path, resolvedProjectRoot)
1744
+ ) {
1745
+ return undefined;
1746
+ }
1747
+ if (!rejected.has(identity.key)) return identity.path;
1748
+ const parent = path.dirname(current);
1749
+ if (parent === current) return undefined;
1750
+ const fallback = nearestExistingDirectory(parent);
1751
+ if (fallback === undefined) return undefined;
1752
+ current = fallback;
1753
+ }
1754
+ }
1755
+
1756
+ function projectInputRecursiveWatchRoot(
1757
+ target: string,
1758
+ projectRoot: string,
1759
+ identities = createProjectInputPathIdentityContext(),
1760
+ ): string | undefined {
1761
+ const resolvedTarget = path.resolve(target);
1762
+ const resolvedProjectRoot = path.resolve(projectRoot);
1763
+ if (identities.isWithin(resolvedProjectRoot, resolvedTarget)) {
1764
+ return nearestExistingDirectory(resolvedProjectRoot);
1765
+ }
1766
+ // An external anchor rises to the declared parent so a tree that does not
1767
+ // exist yet is still observed, and so siblings under it share one handle. It
1768
+ // may not rise past the project, though: a directory that contains the
1769
+ // project swallows the project's own root when the two are merged, and every
1770
+ // in-project declaration then rides one recursive handle over a shared system
1771
+ // directory — a temp root, or the filesystem root itself — which delivers
1772
+ // nothing. Prefer the declared parent, fall back to the target's own tree,
1773
+ // and decline rather than widen past the project.
1774
+ for (const candidate of [
1775
+ nearestExistingDirectory(path.dirname(resolvedTarget)),
1776
+ nearestExistingDirectory(resolvedTarget),
1777
+ ]) {
1778
+ if (candidate === undefined) continue;
1779
+ // The project root cannot outrank itself in the merge, so it is the one
1780
+ // container that is never a swallow — it is the owner the internal branch
1781
+ // would have chosen anyway. A declaration reached through an in-project
1782
+ // directory symlink lands here, and rejecting it would drop the hoist that
1783
+ // keeps a replaced directory from stranding a child handle.
1784
+ if (
1785
+ identities.resolve(candidate).key !==
1786
+ identities.resolve(resolvedProjectRoot).key &&
1787
+ identities.isWithin(candidate, resolvedProjectRoot)
1788
+ ) {
1789
+ continue;
1790
+ }
1791
+ return candidate;
1792
+ }
1793
+ return undefined;
1794
+ }
1795
+
1796
+ /**
1797
+ * A cheap identity for a tracked file's current bytes.
1798
+ *
1799
+ * Modification time and size together answer "did this move" without reading
1800
+ * the file, which is all the unnamed-event path needs; a file that vanished
1801
+ * answers as absent rather than as unchanged.
1802
+ */
1803
+ function compilerFileStamp(location: string): string {
1804
+ try {
1805
+ const stats = fs.statSync(location);
1806
+ return `${stats.mtimeMs}:${stats.size}`;
1807
+ } catch {
1808
+ return "";
1809
+ }
1810
+ }
1811
+
1812
+ function isSymbolicLink(location: string): boolean {
1813
+ try {
1814
+ return fs.lstatSync(location).isSymbolicLink();
1815
+ } catch {
1816
+ return false;
1817
+ }
1818
+ }
1819
+
1820
+ function nearestExistingDirectory(location: string): string | undefined {
1821
+ let current = path.resolve(location);
1822
+ while (true) {
1823
+ if (isDirectory(current)) return current;
1824
+ const parent = path.dirname(current);
1825
+ if (parent === current) return undefined;
1826
+ current = parent;
1827
+ }
1828
+ }
1829
+
1830
+ function matchesProjectInput(
1831
+ snapshot: ITtscProjectInputSnapshot,
1832
+ location: string,
1833
+ identities = createProjectInputPathIdentityContext(),
1834
+ ): boolean {
1835
+ const key = identities.resolve(location).key;
1836
+ return (
1837
+ snapshot.files.some((file) => identities.resolve(file).key === key) ||
1838
+ (snapshot.reloadFiles ?? []).some(
1839
+ (file) => identities.resolve(file).key === key,
1840
+ ) ||
1841
+ (snapshot.reloadDirectories ?? []).some((directory) =>
1842
+ identities.isWithin(directory, location),
1843
+ ) ||
1844
+ snapshot.globs.some((glob) =>
1845
+ matchesProjectInputGlob(glob, location, identities),
1846
+ )
1847
+ );
1848
+ }
1849
+
1850
+ /**
1851
+ * Whether a replacement at this path leaves a recursive watcher bound to the
1852
+ * object that was replaced.
1853
+ *
1854
+ * Only one backend needs the answer. Node routes a recursive watch to its own
1855
+ * per-directory implementation when the platform is neither macOS nor Windows,
1856
+ * and that implementation keys its handles by path: the handle for a directory
1857
+ * renamed away stays bound to the object that left, and any child whose name
1858
+ * survives the swap is skipped as already known. The native subtree backends
1859
+ * both other platforms use follow the path, so retiring their watcher would buy
1860
+ * nothing and would open a window in which no events are delivered.
1861
+ *
1862
+ * The answer is deliberately narrower than the rescan rule, because
1863
+ * reinstalling a root costs one watch descriptor per entry beneath it, which an
1864
+ * install storm would pay thousands of times. A directory appearing inside a
1865
+ * glob root deserves a rescan but replaces nothing a root stands on, and a
1866
+ * reload directory anchors the directory that contains it rather than itself,
1867
+ * since its fingerprint is a digest of its own immediate entries and nothing
1868
+ * below it can reach the declared corpus.
1869
+ */
1870
+ export function projectInputReplacementStrandsWatchers(
1871
+ snapshot: ITtscProjectInputSnapshot,
1872
+ location: string,
1873
+ identities = createProjectInputPathIdentityContext(),
1874
+ platform: NodeJS.Platform = process.platform,
1875
+ ): boolean {
1876
+ if (platform === "darwin" || platform === "win32") return false;
1877
+ const changed = path.resolve(location);
1878
+ if (!isDirectory(changed)) return false;
1879
+ return projectInputAnchorsDeclaration(
1880
+ snapshot,
1881
+ path.dirname(changed),
1882
+ identities,
1883
+ );
1884
+ }
1885
+
1886
+ function projectInputAnchorsDeclaration(
1887
+ snapshot: ITtscProjectInputSnapshot,
1888
+ directory: string,
1889
+ identities: ProjectInputPathIdentityContext,
1890
+ ): boolean {
1891
+ return (
1892
+ snapshot.files.some((file) => identities.isWithin(directory, file)) ||
1893
+ (snapshot.reloadFiles ?? []).some((file) =>
1894
+ identities.isWithin(directory, file),
1895
+ ) ||
1896
+ (snapshot.reloadDirectories ?? []).some(
1897
+ (entry) =>
1898
+ // A reload directory anchors the directory that contains it, never
1899
+ // itself. Its own fingerprint is a digest of its immediate entries, so
1900
+ // nothing below it can reach the corpus, and treating it as its own
1901
+ // anchor would rearm for every entry created directly inside it —
1902
+ // including `node_modules`, which contributors publish as one.
1903
+ identities.isWithin(directory, entry) &&
1904
+ identities.resolve(directory).key !== identities.resolve(entry).key,
1905
+ ) ||
1906
+ snapshot.globs.some((glob) =>
1907
+ identities.isWithin(directory, literalGlobRoot(glob)),
1908
+ )
1909
+ );
1910
+ }
1911
+
1912
+ /**
1913
+ * Decide whether an event that named no declared input can still have moved
1914
+ * one.
1915
+ *
1916
+ * The admitted set is the only bound on how often a watch session re-reads and
1917
+ * re-hashes its declared corpus, and both directions cost: too narrow drops an
1918
+ * atomic replacement, too wide re-fingerprints on every entry an install
1919
+ * creates. Exported so that boundary is pinned directly instead of being
1920
+ * inferred from a rebuild that a silent rescan and a skipped rescan produce
1921
+ * identically.
1922
+ */
1923
+ export function projectInputTopologyMayAffect(
1924
+ snapshot: ITtscProjectInputSnapshot,
1925
+ location: string,
1926
+ previous: ReadonlyMap<string, string>,
1927
+ identities = createProjectInputPathIdentityContext(),
1928
+ ): boolean {
1929
+ const changed = path.resolve(location);
1930
+ const anchors = (directory: string): boolean =>
1931
+ projectInputAnchorsDeclaration(snapshot, directory, identities);
1932
+ // An atomic replacement never names the declared file whose bytes it changed;
1933
+ // it names the directory that was swapped, and that directory can be one the
1934
+ // declaration does not contain — renaming `docs` away reports the arriving
1935
+ // `docs-old`, not `docs`. So a directory event is admitted from where it
1936
+ // happened rather than from what it contains: its own parent must already lie
1937
+ // on the path to a declared input. A tree no declaration reaches, such as
1938
+ // `node_modules` under an ordinary project, then costs nothing per created
1939
+ // entry instead of a population rescan and a full content re-fingerprint. A
1940
+ // glob whose literal root covers that tree still admits every directory
1941
+ // beneath it through the branch below, because a directory appearing inside a
1942
+ // glob root can hold matches; the declaration decides that reach, not this
1943
+ // rule.
1944
+ if (isDirectory(changed) && anchors(path.dirname(changed))) return true;
1945
+ return (
1946
+ anchors(changed) ||
1947
+ (snapshot.reloadDirectories ?? []).some((directory) =>
1948
+ identities.isWithin(directory, changed),
1949
+ ) ||
1950
+ snapshot.globs.some((glob) => {
1951
+ const root = literalGlobRoot(glob);
1952
+ if (identities.isWithin(changed, root)) return true;
1953
+ if (identities.isWithin(root, changed) === false) return false;
1954
+ if (isDirectory(changed)) return true;
1955
+ return [...previous.values()].some((input) =>
1956
+ identities.isWithin(changed, input),
1957
+ );
1958
+ })
1959
+ );
1960
+ }
1961
+
1962
+ export function projectInputEventShouldNotify(input: {
1963
+ contentChanged: boolean;
1964
+ directlyMatched: boolean;
1965
+ membershipChanged: boolean;
1966
+ }): boolean {
1967
+ return input.contentChanged || input.membershipChanged;
1968
+ }
1969
+
1970
+ function projectInputChangedPaths(input: {
1971
+ next: ReadonlyMap<string, string>;
1972
+ nextFingerprints: ReadonlyMap<string, string>;
1973
+ previous: ReadonlyMap<string, string>;
1974
+ previousFingerprints: ReadonlyMap<string, string>;
1975
+ }): string[] {
1976
+ const changed = new Map<string, string>();
1977
+ const keys = new Set([
1978
+ ...input.previous.keys(),
1979
+ ...input.next.keys(),
1980
+ ...input.previousFingerprints.keys(),
1981
+ ...input.nextFingerprints.keys(),
1982
+ ]);
1983
+ for (const key of keys) {
1984
+ if (
1985
+ input.previous.has(key) === input.next.has(key) &&
1986
+ input.previousFingerprints.get(key) === input.nextFingerprints.get(key)
1987
+ ) {
1988
+ continue;
1989
+ }
1990
+ const location = input.next.get(key) ?? input.previous.get(key);
1991
+ if (location !== undefined) changed.set(key, location);
1992
+ }
1993
+ return [...changed.values()];
1994
+ }
1995
+
1996
+ /**
1997
+ * Classify an exact execution-selection input ahead of ordinary project data.
1998
+ *
1999
+ * `changedInputs` carries fingerprint or membership deltas, so a filename-less
2000
+ * event can still select the cold lane. A named exact event selects the cold
2001
+ * lane only after the surrounding change detector admits the event; unchanged
2002
+ * bytes remain quiet before this classifier is observed.
2003
+ */
2004
+ export function projectInputReloadEventShouldNotify(input: {
2005
+ changed?: string;
2006
+ changedInputs: readonly string[];
2007
+ reloadDirectories?: readonly string[];
2008
+ reloadFiles: readonly string[];
2009
+ globs?: readonly string[];
2010
+ }): boolean {
2011
+ const identities = createProjectInputPathIdentityContext();
2012
+ const reloadFiles = new Set(
2013
+ input.reloadFiles.map((location) => identities.resolve(location).key),
2014
+ );
2015
+ const reloadDirectories = (input.reloadDirectories ?? []).map((location) =>
2016
+ identities.resolve(location),
2017
+ );
2018
+ // Territory a declared glob is rooted at, judged against the resolution
2019
+ // directory that would otherwise claim it. A project root is published as a
2020
+ // resolution directory because the config lives there, so the directory a
2021
+ // glob is rooted at reads as a new immediate entry the moment it appears --
2022
+ // and appearing is what a declared population does. The data lane already
2023
+ // reports it, with program invalidation when the membership moved, which is
2024
+ // the cold-Program-same-process transition that belongs to data.
2025
+ //
2026
+ // Data can only carve out strictly below a resolution directory. A glob
2027
+ // rooted on that directory, or above it -- `literalGlobRoot` answers with the
2028
+ // volume root for a pattern with no literal prefix -- would otherwise exempt
2029
+ // everything the directory exists to classify, and the selection lane would
2030
+ // retire in silence. Its role predates any glob drawn around it.
2031
+ //
2032
+ // Only glob roots, either way. A declared file sitting directly in a
2033
+ // resolution directory is still a selection surface: its own bytes are what a
2034
+ // project rule reads to decide, once per execution.
2035
+ const globRoots = (input.globs ?? []).map((glob) =>
2036
+ identities.resolve(literalGlobRoot(glob)),
2037
+ );
2038
+ const exemptedFrom = (
2039
+ directory: ProjectInputPathIdentity,
2040
+ location: string,
2041
+ ): boolean =>
2042
+ globRoots.some(
2043
+ (globRoot) =>
2044
+ globRoot.key !== directory.key &&
2045
+ identities.isWithin(directory.path, globRoot.path) &&
2046
+ identities.isWithin(globRoot.path, location),
2047
+ );
2048
+ // A reload directory is a non-recursive surface: its digest covers its
2049
+ // immediate entries' names, kinds, and link targets, so it moves only when the
2050
+ // directory itself moves or when an entry it holds directly does. Matching the
2051
+ // whole subtree instead makes a resolution ancestor -- the lint config graph
2052
+ // publishes every node_modules level it searched, up to the filesystem root --
2053
+ // read every edit beneath it as a selection change, which restarts the sidecar
2054
+ // on each keystroke and ends warm reuse for any project inside one.
2055
+ const holdsAsImmediateEntry = (location: string): boolean => {
2056
+ const parent = identities.resolve(path.dirname(location)).key;
2057
+ return reloadDirectories.some(
2058
+ (directory) =>
2059
+ directory.key === parent && exemptedFrom(directory, location) === false,
2060
+ );
2061
+ };
2062
+ const namesDirectory = (location: string): boolean => {
2063
+ const key = identities.resolve(location).key;
2064
+ return reloadDirectories.some((directory) => directory.key === key);
2065
+ };
2066
+ // A digest delta on the directory itself is the only signal there is when the
2067
+ // entry that appeared is not a declared match, so it cannot simply be dropped
2068
+ // -- but it also cannot say what appeared. The event being classified in the
2069
+ // same pass can: when it names data, the delta it caused is that data's, and
2070
+ // when it names anything else, or nothing at all, the safe reading is that
2071
+ // resolution moved. A missed reselection is a wrong answer; a spare restart is
2072
+ // a slow one.
2073
+ const explains = (directory: ProjectInputPathIdentity): boolean => {
2074
+ if (input.changed === undefined) return false;
2075
+ // Only an immediate entry can move this directory's digest, so only an
2076
+ // immediate entry can account for the delta. Asking whether the event was
2077
+ // data somewhere else answers a different directory's question, and one
2078
+ // exemption would then cancel every other directory's evidence.
2079
+ const parent = identities.resolve(path.dirname(input.changed)).key;
2080
+ return parent === directory.key && exemptedFrom(directory, input.changed);
2081
+ };
2082
+ const isReloadDirectoryEvent = (location: string): boolean =>
2083
+ namesDirectory(location) || holdsAsImmediateEntry(location);
2084
+ const isReloadDirectoryDelta = (location: string): boolean => {
2085
+ if (holdsAsImmediateEntry(location)) return true;
2086
+ const key = identities.resolve(location).key;
2087
+ return reloadDirectories.some(
2088
+ (directory) => directory.key === key && explains(directory) === false,
2089
+ );
2090
+ };
2091
+ return (
2092
+ (input.changed !== undefined &&
2093
+ (reloadFiles.has(identities.resolve(input.changed).key) ||
2094
+ isReloadDirectoryEvent(input.changed))) ||
2095
+ input.changedInputs.some(
2096
+ (location) =>
2097
+ reloadFiles.has(identities.resolve(location).key) ||
2098
+ isReloadDirectoryDelta(location),
2099
+ )
2100
+ );
2101
+ }
2102
+
2103
+ /**
2104
+ * Return whether a project-input population transition can reshape a Program.
2105
+ *
2106
+ * JSON is data to a ProjectRule but may simultaneously be a `resolveJsonModule`
2107
+ * source. TypeScript and JavaScript paths can likewise overlap a project-input
2108
+ * declaration. Their creation or deletion therefore requires a cold Program
2109
+ * inside the existing resident process. A filename-less event cannot identify
2110
+ * the changed member and is conservatively invalidating whenever the population
2111
+ * moved.
2112
+ */
2113
+ export function projectInputMembershipInvalidatesProgram(input: {
2114
+ changed?: string;
2115
+ changedInputs?: readonly string[];
2116
+ contentChanged?: boolean;
2117
+ next: ReadonlyMap<string, string>;
2118
+ previous: ReadonlyMap<string, string>;
2119
+ }): boolean {
2120
+ if (
2121
+ input.contentChanged === true &&
2122
+ (
2123
+ input.changedInputs ??
2124
+ (input.changed === undefined ? [] : [input.changed])
2125
+ ).some(
2126
+ (location) => path.basename(location).toLowerCase() === "package.json",
2127
+ )
2128
+ ) {
2129
+ return true;
2130
+ }
2131
+ if (mapsEqual(input.previous, input.next)) return false;
2132
+ if (input.changed === undefined) return true;
2133
+ for (const [key, location] of input.previous) {
2134
+ if (
2135
+ input.next.has(key) === false &&
2136
+ projectInputPathMayAffectProgram(location)
2137
+ ) {
2138
+ return true;
2139
+ }
2140
+ }
2141
+ for (const [key, location] of input.next) {
2142
+ if (
2143
+ input.previous.has(key) === false &&
2144
+ projectInputPathMayAffectProgram(location)
2145
+ ) {
2146
+ return true;
2147
+ }
2148
+ }
2149
+ return false;
2150
+ }
2151
+
2152
+ function projectInputPathMayAffectProgram(location: string): boolean {
2153
+ const extension = path.extname(location).toLowerCase();
2154
+ return extension === ".json" || isCompilerEmittableSourceExtension(extension);
2155
+ }
2156
+
2157
+ function projectInputCompilerMembershipChange(
2158
+ snapshot: ITtscProjectInputSnapshot,
2159
+ previous: ReadonlyMap<string, string>,
2160
+ next: ReadonlyMap<string, string>,
2161
+ ): string[] | undefined {
2162
+ const changed = new Map<string, string>();
2163
+ for (const [key, location] of previous) {
2164
+ if (next.has(key) === false) changed.set(key, location);
2165
+ }
2166
+ for (const [key, location] of next) {
2167
+ if (previous.has(key) === false) changed.set(key, location);
2168
+ }
2169
+ if (
2170
+ changed.size === 0 ||
2171
+ [...changed.values()].some(
2172
+ (location) =>
2173
+ matchesProjectInput(snapshot, location) === false ||
2174
+ projectInputPathMayAffectProgram(location) === false,
2175
+ )
2176
+ ) {
2177
+ return undefined;
2178
+ }
2179
+ return [...changed.values()].sort();
2180
+ }
2181
+
2182
+ function fingerprintProjectInputMatches(
2183
+ matches: ReadonlyMap<string, string>,
2184
+ ): Map<string, string> {
2185
+ const fingerprints = new Map<string, string>();
2186
+ for (const [key, location] of matches) {
2187
+ try {
2188
+ fingerprints.set(
2189
+ key,
2190
+ isDirectory(location)
2191
+ ? fingerprintProjectInputDirectory(location)
2192
+ : crypto
2193
+ .createHash("sha256")
2194
+ .update(fs.readFileSync(location))
2195
+ .digest("hex"),
2196
+ );
2197
+ } catch {
2198
+ fingerprints.set(key, "");
2199
+ }
2200
+ }
2201
+ return fingerprints;
2202
+ }
2203
+
2204
+ function fingerprintProjectInputDirectory(location: string): string {
2205
+ const entries = fs
2206
+ .readdirSync(location, { withFileTypes: true })
2207
+ .map((entry) => {
2208
+ const kind = entry.isDirectory()
2209
+ ? "directory"
2210
+ : entry.isFile()
2211
+ ? "file"
2212
+ : entry.isSymbolicLink()
2213
+ ? "symlink"
2214
+ : "other";
2215
+ let target = "";
2216
+ if (entry.isSymbolicLink()) {
2217
+ try {
2218
+ target = fs.readlinkSync(path.join(location, entry.name));
2219
+ } catch {
2220
+ target = "<unreadable>";
2221
+ }
2222
+ }
2223
+ return entry.name + "\0" + kind + "\0" + target;
2224
+ })
2225
+ .sort();
2226
+ return crypto.createHash("sha256").update(entries.join("\0")).digest("hex");
2227
+ }
2228
+
2229
+ function matchesProjectInputGlob(
2230
+ pattern: string,
2231
+ location: string,
2232
+ identities = createProjectInputPathIdentityContext(),
2233
+ ): boolean {
2234
+ const root = identities.resolve(literalGlobRoot(pattern));
2235
+ const candidate = identities.resolve(location);
2236
+ if (!isProjectInputPathIdentityWithin(root.key, candidate.key)) return false;
2237
+ const sensitive = identities.caseSensitive(root.path);
2238
+ const patternParts = path
2239
+ .relative(root.path, identities.resolve(pattern).path)
2240
+ .split(path.sep);
2241
+ const candidateParts = path
2242
+ .relative(root.path, candidate.path)
2243
+ .split(path.sep);
2244
+ return matchProjectInputGlobParts(
2245
+ sensitive
2246
+ ? patternParts
2247
+ : patternParts.map((segment) => segment.toLowerCase()),
2248
+ sensitive
2249
+ ? candidateParts
2250
+ : candidateParts.map((segment) => segment.toLowerCase()),
2251
+ );
2252
+ }
2253
+
2254
+ function matchProjectInputGlobParts(
2255
+ pattern: readonly string[],
2256
+ candidate: readonly string[],
2257
+ ): boolean {
2258
+ const memo = new Map<string, boolean>();
2259
+ const visit = (patternIndex: number, candidateIndex: number): boolean => {
2260
+ const key = `${String(patternIndex)}:${String(candidateIndex)}`;
2261
+ const cached = memo.get(key);
2262
+ if (cached !== undefined) return cached;
2263
+ let matched: boolean;
2264
+ if (patternIndex === pattern.length) {
2265
+ matched = candidateIndex === candidate.length;
2266
+ } else if (pattern[patternIndex] === "**") {
2267
+ matched =
2268
+ visit(patternIndex + 1, candidateIndex) ||
2269
+ (candidateIndex !== candidate.length &&
2270
+ visit(patternIndex, candidateIndex + 1));
2271
+ } else {
2272
+ matched =
2273
+ candidateIndex !== candidate.length &&
2274
+ matchProjectInputGlobSegment(
2275
+ pattern[patternIndex]!,
2276
+ candidate[candidateIndex]!,
2277
+ ) &&
2278
+ visit(patternIndex + 1, candidateIndex + 1);
2279
+ }
2280
+ memo.set(key, matched);
2281
+ return matched;
2282
+ };
2283
+ return visit(0, 0);
2284
+ }
2285
+
2286
+ function matchProjectInputGlobSegment(
2287
+ pattern: string,
2288
+ candidate: string,
2289
+ ): boolean {
2290
+ let source = "^";
2291
+ for (const char of pattern) {
2292
+ if (char === "*") source += ".*";
2293
+ else if (char === "?") source += ".";
2294
+ else source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
2295
+ }
2296
+ return new RegExp(`${source}$`, "u").test(candidate);
2297
+ }
2298
+
432
2299
  function mapsEqual(
433
2300
  left: ReadonlyMap<string, string>,
434
2301
  right: ReadonlyMap<string, string>,
@@ -456,11 +2323,93 @@ function isPathWithin(root: string, candidate: string): boolean {
456
2323
  );
457
2324
  }
458
2325
 
2326
+ /**
2327
+ * Plan one compiler-directory event without relying on backend timing.
2328
+ *
2329
+ * POSIX file watchers own ordinary content changes. A named rename re-arms the
2330
+ * replaced file; an unnamed event conservatively re-arms and reports every
2331
+ * surviving tracked input below the watch root. Windows has no per-file
2332
+ * watchers here, so both named and unnamed directory events report inputs.
2333
+ */
2334
+ export function planCompilerDirectoryWatchEvent(input: {
2335
+ changed?: string;
2336
+ event: string;
2337
+ exists(location: string): boolean;
2338
+ location: string;
2339
+ platform: NodeJS.Platform;
2340
+ trackedFiles: ReadonlyMap<string, string>;
2341
+ }): CompilerDirectoryWatchEventPlan {
2342
+ const candidates =
2343
+ input.changed === undefined
2344
+ ? [...input.trackedFiles.values()].filter(
2345
+ (file) =>
2346
+ input.exists(file) &&
2347
+ isPathWithin(input.location, path.resolve(file)),
2348
+ )
2349
+ : input.trackedFiles.has(
2350
+ pathKeyForPlatform(input.changed, input.platform),
2351
+ ) && input.exists(input.changed)
2352
+ ? [input.changed]
2353
+ : [];
2354
+ if (input.changed === undefined) {
2355
+ return {
2356
+ changes: candidates,
2357
+ rearm: input.platform === "win32" ? [] : candidates,
2358
+ refresh: true,
2359
+ };
2360
+ }
2361
+ if (candidates.length === 0) {
2362
+ return { changes: [], rearm: [], refresh: true };
2363
+ }
2364
+ if (input.platform === "win32") {
2365
+ return { changes: candidates, rearm: [], refresh: false };
2366
+ }
2367
+ if (input.event === "rename") {
2368
+ return { changes: candidates, rearm: candidates, refresh: false };
2369
+ }
2370
+ return { changes: [], rearm: [], refresh: false };
2371
+ }
2372
+
459
2373
  function isRecord(value: unknown): value is Record<string, unknown> {
460
2374
  return typeof value === "object" && value !== null;
461
2375
  }
462
2376
 
2377
+ /**
2378
+ * Resolve the spelling a filesystem watcher must be registered under.
2379
+ *
2380
+ * A watch declaration keeps its lexical spelling, because classification,
2381
+ * containment, and notification are all expressed in the caller's own paths.
2382
+ * The backend needs the canonical spelling instead. libuv stores the directory
2383
+ * string it was handed, expands each delivered event that still exists on disk
2384
+ * to its long path, and then requires the stored string to be that expansion's
2385
+ * prefix. A short (8.3) component makes the two disagree, and the resulting
2386
+ * assertion aborts the whole process (libuv issue 5010; upstream now returns an
2387
+ * error instead, but the bundled libuv every supported Node ships still
2388
+ * asserts). `os.tmpdir()` routinely yields such a path, so ordinary projects
2389
+ * reach it, not only tests. macOS matches events against the watched path
2390
+ * resolved through symlinks, so `/var` and `/private/var` must not be mixed
2391
+ * either. Resolving here keeps every backend comparing two canonical spellings
2392
+ * while callers keep resolving events against what they declared.
2393
+ */
2394
+ function watcherRegistrationPath(location: string): string {
2395
+ try {
2396
+ return fs.realpathSync.native?.(location) ?? fs.realpathSync(location);
2397
+ } catch {
2398
+ // A vanished target makes fs.watch throw anyway, which syncWatchers already
2399
+ // routes to the error path; an unreadable ancestor is better watched under
2400
+ // its declared spelling than not watched at all.
2401
+ return location;
2402
+ }
2403
+ }
2404
+
463
2405
  function pathKey(location: string): string {
2406
+ return pathKeyForPlatform(location, process.platform);
2407
+ }
2408
+
2409
+ function pathKeyForPlatform(
2410
+ location: string,
2411
+ platform: NodeJS.Platform,
2412
+ ): string {
464
2413
  const resolved = path.resolve(location);
465
- return process.platform === "win32" ? resolved.toLowerCase() : resolved;
2414
+ return platform === "win32" ? resolved.toLowerCase() : resolved;
466
2415
  }