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
@@ -39,6 +39,7 @@ export function runSingleFileEmit(options: TtscSingleFileEmitOptions): string {
39
39
  cwd,
40
40
  emit: true,
41
41
  forceListEmittedFiles: true,
42
+ isolateOutputsTo: outDir,
42
43
  outDir,
43
44
  resolvedProject: project,
44
45
  tsconfig,
@@ -156,12 +156,9 @@ export function parseFlags(opts: ParseOptions): ParseResult {
156
156
  // `--target all` masquerade as `--all` now that the lookup is dash- and
157
157
  // case-insensitive.
158
158
  if (current.startsWith("-")) {
159
- // `--foo=value` / `-p=value` form: split before resolving against the
160
- // schema. Both long (`--foo`) and short (`-p`) aliases support the
161
- // inline-value shape — without splitting the short form, `-p=value`
162
- // would fall through as an unknown token and be forwarded to tsgo,
163
- // bypassing the launcher's own consumer (e.g. plugin discovery against
164
- // the wrong project root).
159
+ // Split `--foo=value` / `-p=value` before resolving launcher-owned
160
+ // options. A tsgo-only option is still forwarded byte-for-byte; pinned
161
+ // tsgo does not split `=`, so it will reject that spelling itself.
165
162
  const equalsIndex = current.indexOf("=");
166
163
  const token =
167
164
  equalsIndex === -1 ? current : current.slice(0, equalsIndex);
@@ -369,13 +366,10 @@ function validatePositiveInt(
369
366
  * `--tsgo-args`); without this branch the parser would lose the value token of
370
367
  * a `--flag value` pair.
371
368
  *
372
- * `isPositional` is the same predicate the main loop applies, and applying it
373
- * here is what makes the file's own claim true — that the two value-resolution
374
- * paths agree, and that the predicate is the only signal separating a forwarded
375
- * value from a real input. Without it this branch took the next bare token on
376
- * behalf of a flag that does not own it, so `ttsc --pretty a.ts` lost `a.ts`
377
- * out of `positional` and silently switched from single-file mode to project
378
- * mode (issue #663's failure shape, reached through the sibling branch).
369
+ * A value option owned by tsgo always consumes its next bare token, even when
370
+ * it ends in `.ts`; `--rootDir src.ts main.ts` has one option value and one
371
+ * source. Schema rows belonging only to another ttsc layer still consult
372
+ * `isPositional`, because tsgo does not own their arity.
379
373
  */
380
374
  function forwardKnownButUnaccepted(
381
375
  passthrough: string[],
@@ -386,15 +380,21 @@ function forwardKnownButUnaccepted(
386
380
  isPositional: ((token: string) => boolean) | undefined,
387
381
  ): void {
388
382
  passthrough.push(original);
389
- // Boolean flags carry no value. `--foo=value` already encodes the value
390
- // inline. Otherwise the next argv token is the flag's value: keep it
391
- // adjacent so tsgo receives the pair intact.
383
+ // Boolean flags carry no required value. `--foo=value` is already one token.
392
384
  if (flag.kind === "boolean" || inlineValue !== undefined) {
393
385
  return;
394
386
  }
395
387
  if (rest.length === 0) return;
396
388
  if (rest[0]!.startsWith("-")) return;
397
- if (isPositional !== undefined && isPositional(rest[0]!)) return;
389
+ const tsgoOwnsArity =
390
+ flag.consumedBy.includes("tsgo") || flag.forwardTo === "tsgo";
391
+ if (
392
+ tsgoOwnsArity === false &&
393
+ isPositional !== undefined &&
394
+ isPositional(rest[0]!)
395
+ ) {
396
+ return;
397
+ }
398
398
  passthrough.push(rest.shift()!);
399
399
  }
400
400
 
@@ -61,9 +61,10 @@ export type FlagLayer =
61
61
  /**
62
62
  * Argument shape of a flag.
63
63
  *
64
- * - `boolean` — `--flag` / `--flag=true` / `--flag=false`. No value token is
65
- * consumed.
66
- * - `value` — `--flag value` or `--flag=value`. Required value.
64
+ * - `boolean` — `--flag` with an optional spaced `true` / `false`. Launcher flags
65
+ * also accept `--flag=true` / `--flag=false`; tsgo-forwarded flags stay
66
+ * verbatim because pinned tsgo does not split `=`.
67
+ * - `value` — `--flag value`. Launcher flags also accept `--flag=value`.
67
68
  * - `valueOptional` — `--flag` standalone is allowed; if followed by a non-flag
68
69
  * token that token is consumed as the value. (Currently unused — declared for
69
70
  * future flags like `--watch [path]`.)
@@ -161,6 +162,13 @@ export interface FlagSpec {
161
162
  */
162
163
  readonly internalShadow?: boolean;
163
164
 
165
+ /**
166
+ * `true` when pinned tsgo only permits the option in tsconfig, except for
167
+ * command-line `false` or `null`. The launcher forwards the original argv so
168
+ * tsgo remains the diagnostic authority.
169
+ */
170
+ readonly tsconfigOnly?: boolean;
171
+
164
172
  /**
165
173
  * Native sidecar capability that must be declared before ttsc sends this flag
166
174
  * as a bare CLI argument. Everything else routes through `--tsgo-args` or
@@ -275,6 +283,106 @@ export const FLAG_SCHEMA: readonly FlagSpec[] = [
275
283
  consumedBy: ["launcher", "host", "lint"],
276
284
  description: "Override compilerOptions.outDir for this invocation.",
277
285
  },
286
+ {
287
+ name: "--composite",
288
+ kind: "boolean",
289
+ subcommands: ["ttsc", "ttsx", "build", "check", "fix", "format"],
290
+ consumedBy: ["tsgo"],
291
+ forwardTo: "tsgo",
292
+ tsconfigOnly: true,
293
+ description:
294
+ "Configure project-reference constraints in tsconfig; CLI accepts only false or null.",
295
+ },
296
+ {
297
+ name: "--incremental",
298
+ aliases: ["-i"],
299
+ kind: "boolean",
300
+ subcommands: ["ttsc", "ttsx", "build", "check", "fix", "format"],
301
+ consumedBy: ["tsgo"],
302
+ forwardTo: "tsgo",
303
+ description: "Write build information for incremental compilation.",
304
+ },
305
+ {
306
+ name: "--tsBuildInfoFile",
307
+ kind: "value",
308
+ subcommands: ["ttsc", "ttsx", "build", "check", "fix", "format"],
309
+ consumedBy: ["tsgo"],
310
+ forwardTo: "tsgo",
311
+ description: "Choose the incremental build-information file.",
312
+ },
313
+ {
314
+ name: "--declaration",
315
+ aliases: ["-d"],
316
+ kind: "boolean",
317
+ subcommands: ["ttsc", "ttsx", "build", "check", "fix", "format"],
318
+ consumedBy: ["tsgo"],
319
+ forwardTo: "tsgo",
320
+ description: "Emit declaration files.",
321
+ },
322
+ {
323
+ name: "--declarationDir",
324
+ kind: "value",
325
+ subcommands: ["ttsc", "ttsx", "build", "check", "fix", "format"],
326
+ consumedBy: ["tsgo"],
327
+ forwardTo: "tsgo",
328
+ description: "Choose the declaration output directory.",
329
+ },
330
+ {
331
+ name: "--declarationMap",
332
+ kind: "boolean",
333
+ subcommands: ["ttsc", "ttsx", "build", "check", "fix", "format"],
334
+ consumedBy: ["tsgo"],
335
+ forwardTo: "tsgo",
336
+ description: "Emit source maps for declaration files.",
337
+ },
338
+ {
339
+ name: "--emitDeclarationOnly",
340
+ kind: "boolean",
341
+ subcommands: ["ttsc", "ttsx", "build", "check", "fix", "format"],
342
+ consumedBy: ["tsgo"],
343
+ forwardTo: "tsgo",
344
+ description: "Emit declarations without JavaScript.",
345
+ },
346
+ {
347
+ name: "--inlineSourceMap",
348
+ kind: "boolean",
349
+ subcommands: ["ttsc", "ttsx", "build", "check", "fix", "format"],
350
+ consumedBy: ["tsgo"],
351
+ forwardTo: "tsgo",
352
+ description: "Inline source maps into emitted JavaScript.",
353
+ },
354
+ {
355
+ name: "--sourceMap",
356
+ kind: "boolean",
357
+ subcommands: ["ttsc", "ttsx", "build", "check", "fix", "format"],
358
+ consumedBy: ["tsgo"],
359
+ forwardTo: "tsgo",
360
+ description: "Emit external JavaScript source maps.",
361
+ },
362
+ {
363
+ name: "--outFile",
364
+ kind: "value",
365
+ subcommands: ["ttsc", "ttsx", "build", "check", "fix", "format"],
366
+ consumedBy: ["tsgo"],
367
+ forwardTo: "tsgo",
368
+ description: "Bundle emitted JavaScript and declarations into one file.",
369
+ },
370
+ {
371
+ name: "--rootDir",
372
+ kind: "value",
373
+ subcommands: ["ttsc", "ttsx", "build", "check", "fix", "format"],
374
+ consumedBy: ["tsgo"],
375
+ forwardTo: "tsgo",
376
+ description: "Choose the compiler input root.",
377
+ },
378
+ {
379
+ name: "--jsx",
380
+ kind: "value",
381
+ subcommands: ["ttsc", "ttsx", "build", "check", "fix", "format"],
382
+ consumedBy: ["tsgo"],
383
+ forwardTo: "tsgo",
384
+ description: "Choose the JSX emit transform.",
385
+ },
278
386
 
279
387
  // -------------------------------------------------------------------------
280
388
  // Watch mode (launcher only).
@@ -0,0 +1,273 @@
1
+ import childProcess from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export type ProjectInputPathIdentity = {
6
+ key: string;
7
+ path: string;
8
+ };
9
+
10
+ export type ProjectInputPathIdentityOperations = {
11
+ caseSensitive(directory: string): boolean;
12
+ realpath(location: string): string;
13
+ };
14
+
15
+ export type ProjectInputPathIdentityContext = {
16
+ caseSensitive(directory: string): boolean;
17
+ isWithin(root: string, candidate: string): boolean;
18
+ resolve(location: string): ProjectInputPathIdentity;
19
+ };
20
+
21
+ type CachedIdentity = {
22
+ ancestor: string;
23
+ identity: ProjectInputPathIdentity;
24
+ };
25
+
26
+ type CachedRealpath =
27
+ | {
28
+ found: false;
29
+ }
30
+ | {
31
+ found: true;
32
+ path: string;
33
+ };
34
+
35
+ /**
36
+ * Create one filesystem-identity resolver for a project-input transaction.
37
+ *
38
+ * Existing segments use their physical spelling. A missing suffix keeps exact
39
+ * spelling only under a case-sensitive directory; otherwise its canonical
40
+ * spelling is folded so aliases remain one declaration before they exist.
41
+ */
42
+ export function createProjectInputPathIdentityContext(
43
+ operations: Partial<ProjectInputPathIdentityOperations> = {},
44
+ ): ProjectInputPathIdentityContext {
45
+ const identities = new Map<string, CachedIdentity>();
46
+ const realpaths = new Map<string, CachedRealpath>();
47
+ const sensitivities = new Map<string, boolean>();
48
+ const realpath = operations.realpath ?? physicalRealpath;
49
+ const caseSensitive =
50
+ operations.caseSensitive ?? filesystemDirectoryIsCaseSensitive;
51
+
52
+ const resolve = (location: string): ProjectInputPathIdentity => {
53
+ const normalized = resolveProjectInputPath(location);
54
+ const cached = identities.get(normalized);
55
+ if (cached !== undefined) return cached.identity;
56
+ let existing = normalized;
57
+ const missing: string[] = [];
58
+ while (true) {
59
+ const physical = cachedRealpath(realpaths, realpath, existing);
60
+ if (physical !== undefined) {
61
+ const sensitive =
62
+ missing.length === 0
63
+ ? true
64
+ : cachedCaseSensitivity(sensitivities, caseSensitive, physical);
65
+ const suffix = sensitive
66
+ ? missing
67
+ : missing.map((segment) => segment.toLowerCase());
68
+ const canonical = path.resolve(physical, ...suffix);
69
+ const identity = {
70
+ key: canonical,
71
+ path: canonical,
72
+ };
73
+ identities.set(normalized, {
74
+ ancestor: physical,
75
+ identity,
76
+ });
77
+ return identity;
78
+ }
79
+ const parent = path.dirname(existing);
80
+ if (parent === existing) {
81
+ const identity = {
82
+ key: normalized,
83
+ path: normalized,
84
+ };
85
+ identities.set(normalized, {
86
+ ancestor: normalized,
87
+ identity,
88
+ });
89
+ return identity;
90
+ }
91
+ missing.unshift(path.basename(existing));
92
+ existing = parent;
93
+ }
94
+ };
95
+
96
+ return {
97
+ caseSensitive: (directory) => {
98
+ const normalized = resolveProjectInputPath(directory);
99
+ resolve(normalized);
100
+ return cachedCaseSensitivity(
101
+ sensitivities,
102
+ caseSensitive,
103
+ identities.get(normalized)!.ancestor,
104
+ );
105
+ },
106
+ isWithin: (root, candidate) =>
107
+ isProjectInputPathIdentityWithin(
108
+ resolve(root).key,
109
+ resolve(candidate).key,
110
+ ),
111
+ resolve,
112
+ };
113
+ }
114
+
115
+ export function isProjectInputPathIdentityWithin(
116
+ root: string,
117
+ candidate: string,
118
+ ): boolean {
119
+ if (candidate === root) return true;
120
+ return candidate.startsWith(
121
+ root.endsWith(path.sep) ? root : `${root}${path.sep}`,
122
+ );
123
+ }
124
+
125
+ export function resolveProjectInputPath(location: string): string {
126
+ if (process.platform !== "win32") {
127
+ return path.resolve(location);
128
+ }
129
+ const normalized = location.replaceAll("/", "\\");
130
+ if (normalized.toLowerCase().startsWith("\\\\?\\unc\\")) {
131
+ return path.resolve(`\\\\${normalized.slice(8)}`);
132
+ }
133
+ if (
134
+ normalized.startsWith("\\\\?\\") &&
135
+ /^[A-Za-z]:\\/.test(normalized.slice(4))
136
+ ) {
137
+ return path.resolve(normalized.slice(4));
138
+ }
139
+ return path.resolve(normalized);
140
+ }
141
+
142
+ function cachedRealpath(
143
+ cache: Map<string, CachedRealpath>,
144
+ realpath: (location: string) => string,
145
+ location: string,
146
+ ): string | undefined {
147
+ const cached = cache.get(location);
148
+ if (cached !== undefined) return cached.found ? cached.path : undefined;
149
+ try {
150
+ const physical = resolveProjectInputPath(realpath(location));
151
+ cache.set(location, { found: true, path: physical });
152
+ return physical;
153
+ } catch (error) {
154
+ if (isMissingFilesystemEntry(error) === false) throw error;
155
+ cache.set(location, { found: false });
156
+ return undefined;
157
+ }
158
+ }
159
+
160
+ function cachedCaseSensitivity(
161
+ cache: Map<string, boolean>,
162
+ caseSensitive: (directory: string) => boolean,
163
+ directory: string,
164
+ ): boolean {
165
+ const cached = cache.get(directory);
166
+ if (cached !== undefined) return cached;
167
+ const sensitive = caseSensitive(directory);
168
+ cache.set(directory, sensitive);
169
+ return sensitive;
170
+ }
171
+
172
+ function physicalRealpath(location: string): string {
173
+ return fs.realpathSync.native?.(location) ?? fs.realpathSync(location);
174
+ }
175
+
176
+ function filesystemDirectoryIsCaseSensitive(directory: string): boolean {
177
+ let entries: string[];
178
+ try {
179
+ entries = fs.readdirSync(directory);
180
+ } catch {
181
+ return unprovenCaseSensitivity();
182
+ }
183
+ const foldedNames = new Map<string, string>();
184
+ for (const name of entries) {
185
+ const folded = name.toLowerCase();
186
+ const previous = foldedNames.get(folded);
187
+ if (previous !== undefined && previous !== name) return true;
188
+ foldedNames.set(folded, name);
189
+ }
190
+ for (const name of entries) {
191
+ const alternate = alternateCase(name);
192
+ if (alternate === name) continue;
193
+ try {
194
+ physicalRealpath(path.join(directory, alternate));
195
+ return false;
196
+ } catch (error) {
197
+ if (isMissingFilesystemEntry(error)) continue;
198
+ throw error;
199
+ }
200
+ }
201
+ if (process.platform === "darwin") {
202
+ // APFS/HFS case semantics are volume-wide. An empty directory has no child
203
+ // name to probe, so ask the same read-only question of its existing name in
204
+ // the parent and walk upward until a name with ASCII case is available.
205
+ // This avoids a write probe while still distinguishing default APFS from a
206
+ // case-sensitive volume.
207
+ let current = resolveProjectInputPath(directory);
208
+ while (true) {
209
+ const parent = path.dirname(current);
210
+ if (parent === current) break;
211
+ const name = path.basename(current);
212
+ const alternate = alternateCase(name);
213
+ if (alternate !== name) {
214
+ try {
215
+ physicalRealpath(path.join(parent, alternate));
216
+ return false;
217
+ } catch (error) {
218
+ if (isMissingFilesystemEntry(error) === false) throw error;
219
+ }
220
+ }
221
+ current = parent;
222
+ }
223
+ return unprovenCaseSensitivity();
224
+ }
225
+ if (process.platform !== "win32") return true;
226
+ // Node does not expose the Windows per-directory flag. Prefer fsutil's
227
+ // read-only answer, then conservatively preserve distinct declarations.
228
+ const result = childProcess.spawnSync(
229
+ "fsutil.exe",
230
+ ["file", "queryCaseSensitiveInfo", directory],
231
+ { encoding: "utf8", windowsHide: true },
232
+ );
233
+ if (result.error === undefined && result.status === 0) {
234
+ if (/\bdisabled\b/iu.test(result.stdout)) return false;
235
+ if (/\benabled\b/iu.test(result.stdout)) return true;
236
+ }
237
+ return unprovenCaseSensitivity();
238
+ }
239
+
240
+ /**
241
+ * What to assume when the volume refused to answer.
242
+ *
243
+ * Every probe above returns a proof: two names that fold together prove the
244
+ * volume keeps them apart, and a name that opens under the other case proves it
245
+ * does not. This is the remaining case — an empty or unreadable directory, a
246
+ * name with no letter to alter, a Windows build whose `fsutil` lacks the
247
+ * subcommand — and the only honest answer is the platform's own default.
248
+ *
249
+ * NTFS and APFS are case-insensitive unless a volume or a directory was opted
250
+ * out, so guessing sensitive there splits one file into two identities under
251
+ * two spellings, which is the failure this module exists to remove. The rarer
252
+ * mistake, on a volume that really was opted out, merges two files differing
253
+ * only in case into one watched identity.
254
+ */
255
+ function unprovenCaseSensitivity(): boolean {
256
+ return process.platform !== "win32" && process.platform !== "darwin";
257
+ }
258
+
259
+ function alternateCase(value: string): string {
260
+ return value.replace(/[A-Za-z]/g, (character) =>
261
+ character === character.toLowerCase()
262
+ ? character.toUpperCase()
263
+ : character.toLowerCase(),
264
+ );
265
+ }
266
+
267
+ function isMissingFilesystemEntry(error: unknown): boolean {
268
+ return (
269
+ error instanceof Error &&
270
+ "code" in error &&
271
+ (error.code === "ENOENT" || error.code === "ENOTDIR")
272
+ );
273
+ }