diff --git a/tsc/internal/tsoptions/export_test.go b/tsc/internal/tsoptions/export_test.go index 011b5077d1782..e63f9f7dd0351 100644 --- a/tsc/internal/tsoptions/export_test.go +++ b/tsc/internal/tsoptions/export_test.go @@ -6,6 +6,11 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/vfs" ) +// GetExactFileTestWorker exposes getExactFile for direct unit testing. +func GetExactFileTestWorker(m *collections.OrderedMap[string, string], keyMapper func(value string) string, fileName string) (string, bool) { + return getExactFile(m, keyMapper, fileName) +} + func getTestParseCommandLineWorkerDiagnostics(decls []*CommandLineOption) *ParseCommandLineWorkerDiagnostics { if len(decls) == 0 { return CompilerOptionsDidYouMeanDiagnostics diff --git a/tsc/internal/tsoptions/tsconfigparsing.go b/tsc/internal/tsoptions/tsconfigparsing.go index e491d40221679..68c440f479cc8 100644 --- a/tsc/internal/tsoptions/tsconfigparsing.go +++ b/tsc/internal/tsoptions/tsconfigparsing.go @@ -1871,7 +1871,10 @@ func handleOptionConfigDirTemplateSubstitution(compilerOptions *core.CompilerOpt } // hasFileWithHigherPriorityExtension determines whether a literal or wildcard file has already been included that has a higher extension priority. -// file is the path to the file. +// file is the path to the file. hasFile is called with the exact (case-sensitive) file name that would need to exist to take priority over +// file; it should only return true if a file with that precise name has already been included. This ensures that the extension-priority +// de-duplication (e.g. preferring foo.ts over foo.d.ts or foo.js) never fires across files whose base names differ only by case, since +// those are genuinely distinct files, not an input/output pair, even on a case-insensitive file system. func hasFileWithHigherPriorityExtension(file string, extensions [][]string, hasFile func(fileName string) bool) bool { var extensionGroup []string for _, group := range extensions { @@ -1902,8 +1905,21 @@ func hasFileWithHigherPriorityExtension(file string, extensions [][]string, hasF return false } +// getExactFile looks up fileName in m using keyMapper's (possibly case-folding) canonical key, but +// only returns a match if the entry found actually has that exact (case-sensitive) file name. This +// prevents a canonical-key collision on case-insensitive file systems from being mistaken for a +// match between two files whose base names merely differ in case. +func getExactFile(m *collections.OrderedMap[string, string], keyMapper func(value string) string, fileName string) (string, bool) { + canonicalFileName := keyMapper(fileName) + if existing, ok := m.Get(canonicalFileName); ok && existing == fileName { + return canonicalFileName, true + } + return "", false +} + // Removes files included via wildcard expansion with a lower extension priority that have already been included. -// file is the path to the file. +// file is the path to the file. Only removes an entry if its exact (case-sensitive) file name matches the lower-priority +// name derived from file, so that files whose base names differ only by case are never treated as an input/output pair. func removeWildcardFilesWithLowerPriorityExtension(file string, wildcardFiles *collections.OrderedMap[string, string], extensions [][]string, keyMapper func(value string) string) { var extensionGroup []string for _, group := range extensions { @@ -1919,8 +1935,10 @@ func removeWildcardFilesWithLowerPriorityExtension(file string, wildcardFiles *c if tspath.FileExtensionIs(file, ext) { return } - lowerPriorityPath := keyMapper(tspath.ChangeExtension(file, ext)) - wildcardFiles.Delete(lowerPriorityPath) + lowerPriorityFile := tspath.ChangeExtension(file, ext) + if canonicalFileName, ok := getExactFile(wildcardFiles, keyMapper, lowerPriorityFile); ok { + wildcardFiles.Delete(canonicalFileName) + } } } @@ -1993,9 +2011,17 @@ func getFileNamesFromConfigSpecs( // This handles cases where we may encounter both .ts and // .d.ts (or .js if "allowJs" is enabled) in the same // directory when they are compilation outputs. + // + // The candidate file name must match exactly (case-sensitively) so that + // this de-duplication never conflates two files whose base names differ + // only by case on a case-insensitive file system (e.g. "foo.ts" and + // "Foo.tsx" are different files, not an input/output pair). if hasFileWithHigherPriorityExtension(file, supportedExtensions, func(fileName string) bool { - canonicalFileName := keyMappper(fileName) - return literalFileMap.Has(canonicalFileName) || wildcardFileMap.Has(canonicalFileName) + if _, ok := getExactFile(&literalFileMap, keyMappper, fileName); ok { + return true + } + _, ok := getExactFile(&wildcardFileMap, keyMappper, fileName) + return ok }) { continue } diff --git a/tsc/internal/tsoptions/tsconfigparsing_test.go b/tsc/internal/tsoptions/tsconfigparsing_test.go index 914c62379de42..50bb9f7ad2fb3 100644 --- a/tsc/internal/tsoptions/tsconfigparsing_test.go +++ b/tsc/internal/tsoptions/tsconfigparsing_test.go @@ -949,6 +949,112 @@ func TestParseJsonConfigFileContentDefaultsCompileOnSaveToFalse(t *testing.T) { assert.Equal(t, *parsed.CompileOnSave, false) } +// TestGetExactFile verifies the contract of the getExactFile helper directly: a lookup only +// succeeds if the entry found via the (possibly case-folding) canonical key is an exact, +// case-sensitive match for the requested file name. +func TestGetExactFile(t *testing.T) { + t.Parallel() + + caseInsensitiveKeyMapper := func(value string) string { + return tspath.GetCanonicalFileName(value, false /*useCaseSensitiveFileNames*/) + } + caseSensitiveKeyMapper := func(value string) string { + return tspath.GetCanonicalFileName(value, true /*useCaseSensitiveFileNames*/) + } + + t.Run("exact match is found", func(t *testing.T) { + t.Parallel() + var m collections.OrderedMap[string, string] + m.Set(caseInsensitiveKeyMapper("/project/src/brand.ts"), "/project/src/brand.ts") + key, ok := tsoptions.GetExactFileTestWorker(&m, caseInsensitiveKeyMapper, "/project/src/brand.ts") + assert.Assert(t, ok) + assert.Equal(t, key, caseInsensitiveKeyMapper("/project/src/brand.ts")) + }) + + t.Run("canonical key collision without exact match is rejected", func(t *testing.T) { + t.Parallel() + var m collections.OrderedMap[string, string] + m.Set(caseInsensitiveKeyMapper("/project/src/brand.ts"), "/project/src/brand.ts") + _, ok := tsoptions.GetExactFileTestWorker(&m, caseInsensitiveKeyMapper, "/project/src/Brand.ts") + assert.Assert(t, !ok) + }) + + t.Run("no entry for canonical key", func(t *testing.T) { + t.Parallel() + var m collections.OrderedMap[string, string] + _, ok := tsoptions.GetExactFileTestWorker(&m, caseInsensitiveKeyMapper, "/project/src/brand.ts") + assert.Assert(t, !ok) + }) + + t.Run("case sensitive key mapper never collides", func(t *testing.T) { + t.Parallel() + var m collections.OrderedMap[string, string] + m.Set(caseSensitiveKeyMapper("/project/src/brand.ts"), "/project/src/brand.ts") + _, ok := tsoptions.GetExactFileTestWorker(&m, caseSensitiveKeyMapper, "/project/src/Brand.ts") + assert.Assert(t, !ok) + }) +} + +// TestGetFileNamesFromConfigSpecsCaseInsensitiveBaseNameMismatch verifies that wildcard "include" +// expansion never drops a file just because its base name case-insensitively collides with another, +// differently-cased, unrelated file's base name once its extension is changed to look for a +// higher-priority sibling (e.g. "Brand.tsx" should not be treated as an output of "brand.ts"). +// See https://github.com/microsoft/TypeScript/issues/64098. +func TestGetFileNamesFromConfigSpecsCaseInsensitiveBaseNameMismatch(t *testing.T) { + t.Parallel() + + host := tsoptionstest.NewVFSParseConfigHost(map[string]string{ + "/project/src/brand.ts": `export const brand = "ok";`, + "/project/src/Brand.tsx": `export const oops: number = 1;`, + }, "/project", false /*useCaseSensitiveFileNames*/) + + parsed := tsoptions.ParseJsonConfigFileContent( + map[string]any{ + "compilerOptions": map[string]any{"jsx": "react-jsx"}, + "include": []any{"src/**/*"}, + }, + host, + "/project", + nil, + "/project/tsconfig.json", + nil, /*resolutionStack*/ + nil, /*extendedConfigCache*/ + ) + assert.Equal(t, len(parsed.Errors), 0) + fileNames := slices.Clone(parsed.FileNames()) + slices.Sort(fileNames) + assert.DeepEqual(t, fileNames, []string{"/project/src/Brand.tsx", "/project/src/brand.ts"}) +} + +// TestGetFileNamesFromConfigSpecsSameCaseExtensionPriorityStillApplies verifies that the fix for +// case-insensitive base name mismatches does not regress the legitimate extension-priority +// de-duplication for files that share the exact same base name (e.g. a ".ts" source file should +// still win over its own ".d.ts" and ".js" compilation outputs). +func TestGetFileNamesFromConfigSpecsSameCaseExtensionPriorityStillApplies(t *testing.T) { + t.Parallel() + + host := tsoptionstest.NewVFSParseConfigHost(map[string]string{ + "/project/src/foo.ts": `export const foo = 1;`, + "/project/src/foo.d.ts": `export declare const foo: number;`, + "/project/src/foo.js": `exports.foo = 1;`, + }, "/project", false /*useCaseSensitiveFileNames*/) + + parsed := tsoptions.ParseJsonConfigFileContent( + map[string]any{ + "compilerOptions": map[string]any{"allowJs": true}, + "include": []any{"src/**/*"}, + }, + host, + "/project", + nil, + "/project/tsconfig.json", + nil, /*resolutionStack*/ + nil, /*extendedConfigCache*/ + ) + assert.Equal(t, len(parsed.Errors), 0) + assert.DeepEqual(t, parsed.FileNames(), []string{"/project/src/foo.ts"}) +} + func getParsedWithJsonApi(config testConfig, host tsoptions.ParseConfigHost, basePath string) *tsoptions.ParsedCommandLine { configFileName := tspath.GetNormalizedAbsolutePath(config.configFileName, basePath) path := tspath.ToPath(config.configFileName, basePath, host.FS().UseCaseSensitiveFileNames())