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.
- package/cmd/ttscserver/main.go +79 -3
- package/cmd/ttscserver/run_lsp_uses_native_plugin_manifest_source_test.go +96 -16
- package/driver/lsp.go +4 -0
- package/go.mod +1 -1
- package/internal/lspserver/lsp_document_diagnostics_omits_cached_project_when_current_result_has_none_test.go +48 -0
- package/internal/lspserver/lsp_executable_project_input_change_requests_launcher_restart_test.go +98 -0
- package/internal/lspserver/lsp_external_watched_change_retains_program_test.go +53 -0
- package/internal/lspserver/lsp_hints_refresh_scheduled_by_editor_events_test.go +9 -0
- package/internal/lspserver/lsp_launcher_reload_directory_fingerprint_parity_windows_test.go +94 -0
- package/internal/lspserver/lsp_native_plugin_source.go +171 -55
- package/internal/lspserver/lsp_native_resident.go +110 -9
- package/internal/lspserver/lsp_plugin.go +6 -0
- package/internal/lspserver/lsp_project_context_transport_identity_test.go +43 -0
- package/internal/lspserver/lsp_project_diagnostics.go +235 -0
- package/internal/lspserver/lsp_project_diagnostics_capability_is_independent_test.go +67 -0
- package/internal/lspserver/lsp_project_diagnostics_program_input_overlap_refreshes_all_producers_test.go +78 -0
- package/internal/lspserver/lsp_project_diagnostics_refresh_accumulates_owner_freshness_test.go +65 -0
- package/internal/lspserver/lsp_project_diagnostics_refresh_keeps_each_producer_last_good_test.go +90 -0
- package/internal/lspserver/lsp_project_diagnostics_refreshes_only_input_owners_test.go +62 -0
- package/internal/lspserver/lsp_project_diagnostics_resident_unsupported_falls_back_test.go +123 -0
- package/internal/lspserver/lsp_project_diagnostics_resume_preserves_generation_test.go +52 -0
- package/internal/lspserver/lsp_project_diagnostics_translates_relative_client_project_test.go +90 -0
- package/internal/lspserver/lsp_project_input_glob_path_other.go +16 -0
- package/internal/lspserver/lsp_project_input_glob_path_windows.go +36 -0
- package/internal/lspserver/lsp_project_input_ownership_resolves_declared_spellings_test.go +95 -0
- package/internal/lspserver/lsp_project_input_path_key_other.go +17 -0
- package/internal/lspserver/lsp_project_input_path_key_windows.go +228 -0
- package/internal/lspserver/lsp_project_input_path_key_windows_test.go +460 -0
- package/internal/lspserver/lsp_project_input_registration.go +504 -0
- package/internal/lspserver/lsp_project_input_registration_rechecks_selection_baseline_test.go +71 -0
- package/internal/lspserver/lsp_project_input_registration_reconciles_rejected_pending_request_test.go +268 -0
- package/internal/lspserver/lsp_project_input_registration_shapes_patterns_test.go +94 -0
- package/internal/lspserver/lsp_project_input_watchers_anchor_missing_paths_test.go +48 -0
- package/internal/lspserver/lsp_project_inputs.go +1012 -0
- package/internal/lspserver/lsp_project_inputs_match_missing_exact_and_glob_test.go +129 -0
- package/internal/lspserver/lsp_project_inputs_reject_invalid_and_foreign_snapshots_test.go +110 -0
- package/internal/lspserver/lsp_proxy.go +591 -28
- package/internal/lspserver/lsp_reload_directories_compare_immediate_topology_test.go +188 -0
- package/internal/lspserver/lsp_reload_directories_separate_glob_territory_test.go +181 -0
- package/internal/lspserver/lsp_reload_file_fingerprint_preserves_raw_symlink_target_test.go +47 -0
- package/internal/lspserver/lsp_resident_falls_back_when_serve_unsupported_test.go +1 -1
- package/internal/lspserver/lsp_resident_invalidation_broadcasts_program_input_overlap_test.go +60 -0
- package/internal/lspserver/lsp_resident_invalidation_targets_project_input_owners_test.go +51 -0
- package/internal/lspserver/lsp_same_transport_entries_are_deduplicated_test.go +66 -0
- package/lib/compiler/internal/project/readProjectConfig.d.ts +5 -3
- package/lib/compiler/internal/project/readProjectConfig.js +46 -14
- package/lib/compiler/internal/project/readProjectConfig.js.map +1 -1
- package/lib/compiler/internal/residentCheckProcess.d.ts +46 -0
- package/lib/compiler/internal/residentCheckProcess.js +227 -0
- package/lib/compiler/internal/residentCheckProcess.js.map +1 -0
- package/lib/compiler/internal/resolveEmittedJavaScript.d.ts +2 -1
- package/lib/compiler/internal/resolveEmittedJavaScript.js +35 -18
- package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
- package/lib/compiler/internal/runBuild.d.ts +64 -1
- package/lib/compiler/internal/runBuild.js +484 -7
- package/lib/compiler/internal/runBuild.js.map +1 -1
- package/lib/compiler/internal/runSingleFileEmit.js +1 -0
- package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
- package/lib/flags/parser.js +13 -17
- package/lib/flags/parser.js.map +1 -1
- package/lib/flags/schema.d.ts +10 -3
- package/lib/flags/schema.js +99 -0
- package/lib/flags/schema.js.map +1 -1
- package/lib/internal/projectInputPathIdentity.d.ts +31 -0
- package/lib/internal/projectInputPathIdentity.js +276 -0
- package/lib/internal/projectInputPathIdentity.js.map +1 -0
- package/lib/internal/windowsDirectoryCaseSensitivity.d.ts +10 -0
- package/lib/internal/windowsDirectoryCaseSensitivity.js +34 -0
- package/lib/internal/windowsDirectoryCaseSensitivity.js.map +1 -0
- package/lib/launcher/internal/runTtsc.d.ts +17 -0
- package/lib/launcher/internal/runTtsc.js +126 -88
- package/lib/launcher/internal/runTtsc.js.map +1 -1
- package/lib/launcher/internal/runTtscserver.d.ts +13 -1
- package/lib/launcher/internal/runTtscserver.js +468 -47
- package/lib/launcher/internal/runTtscserver.js.map +1 -1
- package/lib/launcher/internal/runTtsx.js +61 -7
- package/lib/launcher/internal/runTtsx.js.map +1 -1
- package/lib/launcher/internal/runtimeHooks.d.ts +6 -5
- package/lib/launcher/internal/runtimeHooks.js +7 -15
- package/lib/launcher/internal/runtimeHooks.js.map +1 -1
- package/lib/launcher/internal/singleFileOutput.d.ts +16 -0
- package/lib/launcher/internal/singleFileOutput.js +103 -0
- package/lib/launcher/internal/singleFileOutput.js.map +1 -0
- package/lib/launcher/internal/watchTopology.d.ts +288 -2
- package/lib/launcher/internal/watchTopology.js +1921 -54
- package/lib/launcher/internal/watchTopology.js.map +1 -1
- package/lib/plugin/internal/descriptorProcessFailure.d.ts +32 -0
- package/lib/plugin/internal/descriptorProcessFailure.js +73 -0
- package/lib/plugin/internal/descriptorProcessFailure.js.map +1 -0
- package/lib/plugin/internal/loadProjectPlugins.d.ts +1 -0
- package/lib/plugin/internal/loadProjectPlugins.js +34 -8
- package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
- package/lib/structures/ITtscPluginCapabilities.d.ts +31 -0
- package/lib/structures/internal/ITtscProjectInputSnapshot.d.ts +29 -0
- package/lib/structures/internal/ITtscProjectInputSnapshot.js +3 -0
- package/lib/structures/internal/ITtscProjectInputSnapshot.js.map +1 -0
- package/lib/structures/internal/TtscSingleFileEmitOptions.d.ts +5 -0
- package/package.json +12 -8
- package/src/compiler/internal/project/readProjectConfig.ts +62 -8
- package/src/compiler/internal/residentCheckProcess.ts +279 -0
- package/src/compiler/internal/resolveEmittedJavaScript.ts +43 -21
- package/src/compiler/internal/runBuild.ts +728 -12
- package/src/compiler/internal/runSingleFileEmit.ts +1 -0
- package/src/flags/parser.ts +17 -17
- package/src/flags/schema.ts +112 -3
- package/src/internal/projectInputPathIdentity.ts +377 -0
- package/src/internal/windowsDirectoryCaseSensitivity.ts +33 -0
- package/src/launcher/internal/runTtsc.ts +148 -117
- package/src/launcher/internal/runTtscserver.ts +633 -56
- package/src/launcher/internal/runTtsx.ts +87 -10
- package/src/launcher/internal/runtimeHooks.ts +16 -16
- package/src/launcher/internal/singleFileOutput.ts +133 -0
- package/src/launcher/internal/watchTopology.ts +2494 -74
- package/src/plugin/internal/descriptorProcessFailure.ts +100 -0
- package/src/plugin/internal/loadProjectPlugins.ts +49 -8
- package/src/structures/ITtscPluginCapabilities.ts +34 -0
- package/src/structures/internal/ITtscProjectInputSnapshot.ts +29 -0
- package/src/structures/internal/TtscSingleFileEmitOptions.ts +5 -0
- package/tools/shim_audit/baseline.json +0 -9
package/src/flags/parser.ts
CHANGED
|
@@ -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`
|
|
160
|
-
//
|
|
161
|
-
//
|
|
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
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
package/src/flags/schema.ts
CHANGED
|
@@ -61,9 +61,10 @@ export type FlagLayer =
|
|
|
61
61
|
/**
|
|
62
62
|
* Argument shape of a flag.
|
|
63
63
|
*
|
|
64
|
-
* - `boolean` — `--flag`
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
+
}
|