ttsc 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/cmd/ttscserver/main.go +79 -3
  2. package/cmd/ttscserver/run_lsp_uses_native_plugin_manifest_source_test.go +96 -16
  3. package/driver/lsp.go +4 -0
  4. package/go.mod +1 -1
  5. package/internal/lspserver/lsp_document_diagnostics_omits_cached_project_when_current_result_has_none_test.go +48 -0
  6. package/internal/lspserver/lsp_executable_project_input_change_requests_launcher_restart_test.go +98 -0
  7. package/internal/lspserver/lsp_external_watched_change_retains_program_test.go +53 -0
  8. package/internal/lspserver/lsp_hints_refresh_scheduled_by_editor_events_test.go +9 -0
  9. package/internal/lspserver/lsp_launcher_reload_directory_fingerprint_parity_windows_test.go +94 -0
  10. package/internal/lspserver/lsp_native_plugin_source.go +171 -55
  11. package/internal/lspserver/lsp_native_resident.go +110 -9
  12. package/internal/lspserver/lsp_plugin.go +6 -0
  13. package/internal/lspserver/lsp_project_context_transport_identity_test.go +43 -0
  14. package/internal/lspserver/lsp_project_diagnostics.go +235 -0
  15. package/internal/lspserver/lsp_project_diagnostics_capability_is_independent_test.go +67 -0
  16. package/internal/lspserver/lsp_project_diagnostics_program_input_overlap_refreshes_all_producers_test.go +78 -0
  17. package/internal/lspserver/lsp_project_diagnostics_refresh_accumulates_owner_freshness_test.go +65 -0
  18. package/internal/lspserver/lsp_project_diagnostics_refresh_keeps_each_producer_last_good_test.go +90 -0
  19. package/internal/lspserver/lsp_project_diagnostics_refreshes_only_input_owners_test.go +62 -0
  20. package/internal/lspserver/lsp_project_diagnostics_resident_unsupported_falls_back_test.go +123 -0
  21. package/internal/lspserver/lsp_project_diagnostics_resume_preserves_generation_test.go +52 -0
  22. package/internal/lspserver/lsp_project_diagnostics_translates_relative_client_project_test.go +90 -0
  23. package/internal/lspserver/lsp_project_input_glob_path_other.go +16 -0
  24. package/internal/lspserver/lsp_project_input_glob_path_windows.go +36 -0
  25. package/internal/lspserver/lsp_project_input_ownership_resolves_declared_spellings_test.go +95 -0
  26. package/internal/lspserver/lsp_project_input_path_key_other.go +17 -0
  27. package/internal/lspserver/lsp_project_input_path_key_windows.go +228 -0
  28. package/internal/lspserver/lsp_project_input_path_key_windows_test.go +460 -0
  29. package/internal/lspserver/lsp_project_input_registration.go +504 -0
  30. package/internal/lspserver/lsp_project_input_registration_rechecks_selection_baseline_test.go +71 -0
  31. package/internal/lspserver/lsp_project_input_registration_reconciles_rejected_pending_request_test.go +268 -0
  32. package/internal/lspserver/lsp_project_input_registration_shapes_patterns_test.go +94 -0
  33. package/internal/lspserver/lsp_project_input_watchers_anchor_missing_paths_test.go +48 -0
  34. package/internal/lspserver/lsp_project_inputs.go +1012 -0
  35. package/internal/lspserver/lsp_project_inputs_match_missing_exact_and_glob_test.go +129 -0
  36. package/internal/lspserver/lsp_project_inputs_reject_invalid_and_foreign_snapshots_test.go +110 -0
  37. package/internal/lspserver/lsp_proxy.go +591 -28
  38. package/internal/lspserver/lsp_reload_directories_compare_immediate_topology_test.go +188 -0
  39. package/internal/lspserver/lsp_reload_directories_separate_glob_territory_test.go +181 -0
  40. package/internal/lspserver/lsp_reload_file_fingerprint_preserves_raw_symlink_target_test.go +47 -0
  41. package/internal/lspserver/lsp_resident_falls_back_when_serve_unsupported_test.go +1 -1
  42. package/internal/lspserver/lsp_resident_invalidation_broadcasts_program_input_overlap_test.go +60 -0
  43. package/internal/lspserver/lsp_resident_invalidation_targets_project_input_owners_test.go +51 -0
  44. package/internal/lspserver/lsp_same_transport_entries_are_deduplicated_test.go +66 -0
  45. package/lib/compiler/internal/project/readProjectConfig.d.ts +5 -3
  46. package/lib/compiler/internal/project/readProjectConfig.js +46 -14
  47. package/lib/compiler/internal/project/readProjectConfig.js.map +1 -1
  48. package/lib/compiler/internal/residentCheckProcess.d.ts +46 -0
  49. package/lib/compiler/internal/residentCheckProcess.js +227 -0
  50. package/lib/compiler/internal/residentCheckProcess.js.map +1 -0
  51. package/lib/compiler/internal/resolveEmittedJavaScript.d.ts +2 -1
  52. package/lib/compiler/internal/resolveEmittedJavaScript.js +35 -18
  53. package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
  54. package/lib/compiler/internal/runBuild.d.ts +64 -1
  55. package/lib/compiler/internal/runBuild.js +484 -7
  56. package/lib/compiler/internal/runBuild.js.map +1 -1
  57. package/lib/compiler/internal/runSingleFileEmit.js +1 -0
  58. package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
  59. package/lib/flags/parser.js +13 -17
  60. package/lib/flags/parser.js.map +1 -1
  61. package/lib/flags/schema.d.ts +10 -3
  62. package/lib/flags/schema.js +99 -0
  63. package/lib/flags/schema.js.map +1 -1
  64. package/lib/internal/projectInputPathIdentity.d.ts +31 -0
  65. package/lib/internal/projectInputPathIdentity.js +276 -0
  66. package/lib/internal/projectInputPathIdentity.js.map +1 -0
  67. package/lib/internal/windowsDirectoryCaseSensitivity.d.ts +10 -0
  68. package/lib/internal/windowsDirectoryCaseSensitivity.js +34 -0
  69. package/lib/internal/windowsDirectoryCaseSensitivity.js.map +1 -0
  70. package/lib/launcher/internal/runTtsc.d.ts +17 -0
  71. package/lib/launcher/internal/runTtsc.js +126 -88
  72. package/lib/launcher/internal/runTtsc.js.map +1 -1
  73. package/lib/launcher/internal/runTtscserver.d.ts +13 -1
  74. package/lib/launcher/internal/runTtscserver.js +468 -47
  75. package/lib/launcher/internal/runTtscserver.js.map +1 -1
  76. package/lib/launcher/internal/runTtsx.js +61 -7
  77. package/lib/launcher/internal/runTtsx.js.map +1 -1
  78. package/lib/launcher/internal/runtimeHooks.d.ts +6 -5
  79. package/lib/launcher/internal/runtimeHooks.js +7 -15
  80. package/lib/launcher/internal/runtimeHooks.js.map +1 -1
  81. package/lib/launcher/internal/singleFileOutput.d.ts +16 -0
  82. package/lib/launcher/internal/singleFileOutput.js +103 -0
  83. package/lib/launcher/internal/singleFileOutput.js.map +1 -0
  84. package/lib/launcher/internal/watchTopology.d.ts +288 -2
  85. package/lib/launcher/internal/watchTopology.js +1921 -54
  86. package/lib/launcher/internal/watchTopology.js.map +1 -1
  87. package/lib/plugin/internal/descriptorProcessFailure.d.ts +32 -0
  88. package/lib/plugin/internal/descriptorProcessFailure.js +73 -0
  89. package/lib/plugin/internal/descriptorProcessFailure.js.map +1 -0
  90. package/lib/plugin/internal/loadProjectPlugins.d.ts +1 -0
  91. package/lib/plugin/internal/loadProjectPlugins.js +34 -8
  92. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  93. package/lib/structures/ITtscPluginCapabilities.d.ts +31 -0
  94. package/lib/structures/internal/ITtscProjectInputSnapshot.d.ts +29 -0
  95. package/lib/structures/internal/ITtscProjectInputSnapshot.js +3 -0
  96. package/lib/structures/internal/ITtscProjectInputSnapshot.js.map +1 -0
  97. package/lib/structures/internal/TtscSingleFileEmitOptions.d.ts +5 -0
  98. package/package.json +12 -8
  99. package/src/compiler/internal/project/readProjectConfig.ts +62 -8
  100. package/src/compiler/internal/residentCheckProcess.ts +279 -0
  101. package/src/compiler/internal/resolveEmittedJavaScript.ts +43 -21
  102. package/src/compiler/internal/runBuild.ts +728 -12
  103. package/src/compiler/internal/runSingleFileEmit.ts +1 -0
  104. package/src/flags/parser.ts +17 -17
  105. package/src/flags/schema.ts +112 -3
  106. package/src/internal/projectInputPathIdentity.ts +377 -0
  107. package/src/internal/windowsDirectoryCaseSensitivity.ts +33 -0
  108. package/src/launcher/internal/runTtsc.ts +148 -117
  109. package/src/launcher/internal/runTtscserver.ts +633 -56
  110. package/src/launcher/internal/runTtsx.ts +87 -10
  111. package/src/launcher/internal/runtimeHooks.ts +16 -16
  112. package/src/launcher/internal/singleFileOutput.ts +133 -0
  113. package/src/launcher/internal/watchTopology.ts +2494 -74
  114. package/src/plugin/internal/descriptorProcessFailure.ts +100 -0
  115. package/src/plugin/internal/loadProjectPlugins.ts +49 -8
  116. package/src/structures/ITtscPluginCapabilities.ts +34 -0
  117. package/src/structures/internal/ITtscProjectInputSnapshot.ts +29 -0
  118. package/src/structures/internal/TtscSingleFileEmitOptions.ts +5 -0
  119. package/tools/shim_audit/baseline.json +0 -9
@@ -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,107 @@ 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:
369
+ "Forward the removed legacy option for TypeScript-Go's diagnostic.",
370
+ },
371
+ {
372
+ name: "--rootDir",
373
+ kind: "value",
374
+ subcommands: ["ttsc", "ttsx", "build", "check", "fix", "format"],
375
+ consumedBy: ["tsgo"],
376
+ forwardTo: "tsgo",
377
+ description: "Choose the compiler input root.",
378
+ },
379
+ {
380
+ name: "--jsx",
381
+ kind: "value",
382
+ subcommands: ["ttsc", "ttsx", "build", "check", "fix", "format"],
383
+ consumedBy: ["tsgo"],
384
+ forwardTo: "tsgo",
385
+ description: "Choose the JSX emit transform.",
386
+ },
278
387
 
279
388
  // -------------------------------------------------------------------------
280
389
  // Watch mode (launcher only).
@@ -0,0 +1,377 @@
1
+ import childProcess from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { parseWindowsDirectoryCaseSensitivity } from "./windowsDirectoryCaseSensitivity";
6
+
7
+ export type FilesystemPathIdentity = {
8
+ key: string;
9
+ path: string;
10
+ };
11
+
12
+ export type FilesystemPathIdentityOperations = {
13
+ caseSensitive(directory: string): boolean;
14
+ platform: NodeJS.Platform;
15
+ realpath(location: string): string;
16
+ throwOnRealpathError: boolean;
17
+ };
18
+
19
+ export type FilesystemPathIdentityContext = {
20
+ caseSensitive(directory: string): boolean;
21
+ isWithin(root: string, candidate: string): boolean;
22
+ resolve(location: string): FilesystemPathIdentity;
23
+ };
24
+
25
+ type CachedIdentity = {
26
+ ancestor: string;
27
+ identity: FilesystemPathIdentity;
28
+ };
29
+
30
+ type CachedRealpath =
31
+ | {
32
+ found: false;
33
+ }
34
+ | {
35
+ found: true;
36
+ path: string;
37
+ };
38
+
39
+ /**
40
+ * Create one filesystem-identity resolver for a filesystem transaction.
41
+ *
42
+ * Existing segments use their physical spelling. A missing suffix keeps exact
43
+ * spelling only under a case-sensitive directory; otherwise its canonical
44
+ * spelling is folded so aliases remain one declaration before they exist.
45
+ */
46
+ export function createFilesystemPathIdentityContext(
47
+ operations: Partial<FilesystemPathIdentityOperations> = {},
48
+ ): FilesystemPathIdentityContext {
49
+ const identities = new Map<string, CachedIdentity>();
50
+ const realpaths = new Map<string, CachedRealpath>();
51
+ const sensitivities = new Map<string, boolean>();
52
+ const platform = operations.platform ?? process.platform;
53
+ const pathApi = platform === "win32" ? path.win32 : path;
54
+ const throwOnRealpathError = operations.throwOnRealpathError ?? true;
55
+ const realpath = operations.realpath ?? physicalRealpath;
56
+ const caseSensitive =
57
+ operations.caseSensitive ??
58
+ ((directory: string) =>
59
+ filesystemDirectoryIsCaseSensitive(directory, platform));
60
+
61
+ const resolve = (location: string): FilesystemPathIdentity => {
62
+ const normalized = resolveFilesystemPath(location, platform);
63
+ const cached = identities.get(normalized);
64
+ if (cached !== undefined) return cached.identity;
65
+ let existing = normalized;
66
+ const missing: string[] = [];
67
+ while (true) {
68
+ const physical = cachedRealpath(
69
+ realpaths,
70
+ realpath,
71
+ existing,
72
+ platform,
73
+ throwOnRealpathError,
74
+ );
75
+ if (physical !== undefined) {
76
+ const sensitive =
77
+ missing.length === 0
78
+ ? true
79
+ : cachedCaseSensitivity(sensitivities, caseSensitive, physical);
80
+ const suffix = sensitive
81
+ ? missing
82
+ : missing.map((segment) => segment.toLowerCase());
83
+ const canonical = pathApi.resolve(physical, ...suffix);
84
+ const identity = {
85
+ key: filesystemPathIdentityKey(canonical, platform),
86
+ path: canonical,
87
+ };
88
+ identities.set(normalized, {
89
+ ancestor: physical,
90
+ identity,
91
+ });
92
+ return identity;
93
+ }
94
+ const parent = pathApi.dirname(existing);
95
+ if (parent === existing) {
96
+ const sensitive = cachedCaseSensitivity(
97
+ sensitivities,
98
+ caseSensitive,
99
+ existing,
100
+ );
101
+ const identity = {
102
+ key: filesystemPathIdentityKey(
103
+ sensitive ? normalized : normalized.toLowerCase(),
104
+ platform,
105
+ ),
106
+ path: normalized,
107
+ };
108
+ identities.set(normalized, {
109
+ ancestor: normalized,
110
+ identity,
111
+ });
112
+ return identity;
113
+ }
114
+ missing.unshift(pathApi.basename(existing));
115
+ existing = parent;
116
+ }
117
+ };
118
+
119
+ return {
120
+ caseSensitive: (directory) => {
121
+ const normalized = resolveFilesystemPath(directory, platform);
122
+ resolve(normalized);
123
+ return cachedCaseSensitivity(
124
+ sensitivities,
125
+ caseSensitive,
126
+ identities.get(normalized)!.ancestor,
127
+ );
128
+ },
129
+ isWithin: (root, candidate) =>
130
+ isFilesystemPathIdentityWithin(
131
+ resolve(root).key,
132
+ resolve(candidate).key,
133
+ platform,
134
+ ),
135
+ resolve,
136
+ };
137
+ }
138
+
139
+ export function isFilesystemPathIdentityWithin(
140
+ root: string,
141
+ candidate: string,
142
+ platform: NodeJS.Platform = process.platform,
143
+ ): boolean {
144
+ if (candidate === root) return true;
145
+ const separator = platform === "win32" ? path.win32.sep : path.sep;
146
+ return candidate.startsWith(
147
+ root.endsWith(separator) ? root : `${root}${separator}`,
148
+ );
149
+ }
150
+
151
+ export function resolveFilesystemPath(
152
+ location: string,
153
+ platform: NodeJS.Platform = process.platform,
154
+ ): string {
155
+ const pathApi = platform === "win32" ? path.win32 : path;
156
+ if (platform !== "win32") {
157
+ return pathApi.resolve(location);
158
+ }
159
+ const normalized = location.replaceAll("/", "\\");
160
+ if (normalized.toLowerCase().startsWith("\\\\?\\unc\\")) {
161
+ return pathApi.resolve(`\\\\${normalized.slice(8)}`);
162
+ }
163
+ if (
164
+ normalized.startsWith("\\\\?\\") &&
165
+ /^[A-Za-z]:\\/.test(normalized.slice(4))
166
+ ) {
167
+ return pathApi.resolve(normalized.slice(4));
168
+ }
169
+ return pathApi.resolve(normalized);
170
+ }
171
+
172
+ function cachedRealpath(
173
+ cache: Map<string, CachedRealpath>,
174
+ realpath: (location: string) => string,
175
+ location: string,
176
+ platform: NodeJS.Platform,
177
+ throwOnRealpathError: boolean,
178
+ ): string | undefined {
179
+ const cached = cache.get(location);
180
+ if (cached !== undefined) return cached.found ? cached.path : undefined;
181
+ try {
182
+ const physical = resolveFilesystemPath(realpath(location), platform);
183
+ cache.set(location, { found: true, path: physical });
184
+ return physical;
185
+ } catch (error) {
186
+ if (throwOnRealpathError && isMissingFilesystemEntry(error) === false) {
187
+ throw error;
188
+ }
189
+ cache.set(location, { found: false });
190
+ return undefined;
191
+ }
192
+ }
193
+
194
+ function cachedCaseSensitivity(
195
+ cache: Map<string, boolean>,
196
+ caseSensitive: (directory: string) => boolean,
197
+ directory: string,
198
+ ): boolean {
199
+ const cached = cache.get(directory);
200
+ if (cached !== undefined) return cached;
201
+ const sensitive = caseSensitive(directory);
202
+ cache.set(directory, sensitive);
203
+ return sensitive;
204
+ }
205
+
206
+ function physicalRealpath(location: string): string {
207
+ return fs.realpathSync.native?.(location) ?? fs.realpathSync(location);
208
+ }
209
+
210
+ function filesystemDirectoryIsCaseSensitive(
211
+ directory: string,
212
+ platform: NodeJS.Platform,
213
+ ): boolean {
214
+ let entries: string[];
215
+ try {
216
+ entries = fs.readdirSync(directory);
217
+ } catch {
218
+ return unprovenCaseSensitivity(platform);
219
+ }
220
+ const foldedNames = new Map<string, string>();
221
+ for (const name of entries) {
222
+ const folded = name.toLowerCase();
223
+ const previous = foldedNames.get(folded);
224
+ if (previous !== undefined && previous !== name) return true;
225
+ foldedNames.set(folded, name);
226
+ }
227
+ let rejectedAlternate = false;
228
+ for (const name of entries) {
229
+ const alternate = alternateCase(name);
230
+ if (alternate === name) continue;
231
+ try {
232
+ fs.lstatSync(path.join(directory, alternate));
233
+ return false;
234
+ } catch (error) {
235
+ if (isMissingFilesystemEntry(error)) {
236
+ rejectedAlternate = true;
237
+ continue;
238
+ }
239
+ throw error;
240
+ }
241
+ }
242
+ if (rejectedAlternate) return true;
243
+ if (platform === "darwin") {
244
+ // APFS/HFS case semantics are volume-wide. An empty directory has no child
245
+ // name to probe, so ask the same read-only question of its existing name in
246
+ // the parent and walk upward until a name with ASCII case is available.
247
+ // This avoids a write probe while still distinguishing default APFS from a
248
+ // case-sensitive volume.
249
+ let current = resolveFilesystemPath(directory, platform);
250
+ while (true) {
251
+ const parent = path.dirname(current);
252
+ if (parent === current) break;
253
+ const name = path.basename(current);
254
+ const alternate = alternateCase(name);
255
+ if (alternate !== name) {
256
+ try {
257
+ physicalRealpath(path.join(parent, alternate));
258
+ return false;
259
+ } catch (error) {
260
+ if (isMissingFilesystemEntry(error) === false) throw error;
261
+ }
262
+ }
263
+ current = parent;
264
+ }
265
+ return unprovenCaseSensitivity(platform);
266
+ }
267
+ if (platform !== "win32") return true;
268
+ // Node does not expose the Windows per-directory flag. Prefer fsutil's
269
+ // read-only answer, then conservatively preserve distinct declarations.
270
+ const queried = queryWindowsDirectoryCaseSensitivity(directory);
271
+ if (queried !== undefined) return queried;
272
+ return unprovenCaseSensitivity(platform);
273
+ }
274
+
275
+ /**
276
+ * Read the per-directory flag without depending on fsutil's display language.
277
+ *
278
+ * English output is cheap to recognize directly. Other Windows locales write
279
+ * console-code-page bytes that Node cannot reliably decode, so their raw
280
+ * message is interpreted against the volume-root query.
281
+ */
282
+ function queryWindowsDirectoryCaseSensitivity(
283
+ directory: string,
284
+ ): boolean | undefined {
285
+ const result = queryWindowsDirectoryCaseSensitivityBytes(directory);
286
+ if (result === undefined) return undefined;
287
+ const volumeRoot = path.win32.parse(directory).root;
288
+ const direct = parseWindowsDirectoryCaseSensitivity(
289
+ result,
290
+ undefined,
291
+ volumeRoot,
292
+ );
293
+ if (direct !== undefined) return direct;
294
+ const volume = queryWindowsDirectoryCaseSensitivityBytes(volumeRoot);
295
+ return parseWindowsDirectoryCaseSensitivity(result, volume, volumeRoot);
296
+ }
297
+
298
+ function queryWindowsDirectoryCaseSensitivityBytes(
299
+ directory: string,
300
+ ): Buffer | undefined {
301
+ const result = childProcess.spawnSync(
302
+ "fsutil.exe",
303
+ ["file", "queryCaseSensitiveInfo", directory],
304
+ { windowsHide: true },
305
+ );
306
+ return result.error === undefined &&
307
+ result.status === 0 &&
308
+ Buffer.isBuffer(result.stdout)
309
+ ? result.stdout
310
+ : undefined;
311
+ }
312
+
313
+ /**
314
+ * What to assume when the volume refused to answer.
315
+ *
316
+ * Every probe above returns a proof: two names that fold together prove the
317
+ * volume keeps them apart, and a name that opens under the other case proves it
318
+ * does not. This is the remaining case — an empty or unreadable directory, a
319
+ * name with no letter to alter, a Windows build whose `fsutil` lacks the
320
+ * subcommand — and the only honest answer is the platform's own default.
321
+ *
322
+ * NTFS and APFS are case-insensitive unless a volume or a directory was opted
323
+ * out, so guessing sensitive there splits one file into two identities under
324
+ * two spellings, which is the failure this module exists to remove. The rarer
325
+ * mistake, on a volume that really was opted out, merges two files differing
326
+ * only in case into one watched identity.
327
+ */
328
+ function unprovenCaseSensitivity(platform: NodeJS.Platform): boolean {
329
+ return platform !== "win32" && platform !== "darwin";
330
+ }
331
+
332
+ function filesystemPathIdentityKey(
333
+ location: string,
334
+ platform: NodeJS.Platform,
335
+ ): string {
336
+ if (platform !== "win32") return location;
337
+ const root = path.win32.parse(location).root;
338
+ return `${root.toLowerCase()}${location.slice(root.length)}`;
339
+ }
340
+
341
+ function alternateCase(value: string): string {
342
+ return value.replace(/[A-Za-z]/g, (character) =>
343
+ character === character.toLowerCase()
344
+ ? character.toUpperCase()
345
+ : character.toLowerCase(),
346
+ );
347
+ }
348
+
349
+ function isMissingFilesystemEntry(error: unknown): boolean {
350
+ return (
351
+ error instanceof Error &&
352
+ "code" in error &&
353
+ (error.code === "ENOENT" || error.code === "ENOTDIR")
354
+ );
355
+ }
356
+
357
+ export type ProjectInputPathIdentity = FilesystemPathIdentity;
358
+ export type ProjectInputPathIdentityOperations =
359
+ FilesystemPathIdentityOperations;
360
+ export type ProjectInputPathIdentityContext = FilesystemPathIdentityContext;
361
+
362
+ export function createProjectInputPathIdentityContext(
363
+ operations: Partial<ProjectInputPathIdentityOperations> = {},
364
+ ): ProjectInputPathIdentityContext {
365
+ return createFilesystemPathIdentityContext(operations);
366
+ }
367
+
368
+ export function isProjectInputPathIdentityWithin(
369
+ root: string,
370
+ candidate: string,
371
+ ): boolean {
372
+ return isFilesystemPathIdentityWithin(root, candidate);
373
+ }
374
+
375
+ export function resolveProjectInputPath(location: string): string {
376
+ return resolveFilesystemPath(location);
377
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Interpret fsutil's case-sensitivity query without assuming a display
3
+ * language.
4
+ *
5
+ * English messages identify the state directly. For every other locale, the
6
+ * volume-root query supplies the raw localized suffix for the ordinary disabled
7
+ * state. A target with that suffix is insensitive; a different successful
8
+ * message is the enabled state.
9
+ */
10
+ export function parseWindowsDirectoryCaseSensitivity(
11
+ directoryOutput: Buffer,
12
+ volumeOutput: Buffer | undefined,
13
+ volumeRoot: string,
14
+ ): boolean | undefined {
15
+ const text = directoryOutput.toString("utf8");
16
+ if (/\bdisabled\b/iu.test(text)) return false;
17
+ if (/\benabled\b/iu.test(text)) return true;
18
+ if (volumeOutput === undefined) return undefined;
19
+
20
+ const encodedRoot = Buffer.from(volumeRoot, "utf8");
21
+ const rootOffset = volumeOutput.lastIndexOf(encodedRoot);
22
+ if (rootOffset === -1) return undefined;
23
+ const disabledSuffix = volumeOutput.subarray(rootOffset + encodedRoot.length);
24
+ if (
25
+ disabledSuffix.length === 0 ||
26
+ directoryOutput.length < disabledSuffix.length
27
+ ) {
28
+ return undefined;
29
+ }
30
+ return !directoryOutput
31
+ .subarray(directoryOutput.length - disabledSuffix.length)
32
+ .equals(disabledSuffix);
33
+ }