From d586d3ffba379c10d55e975a38d54433edf94b48 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:52:03 +0000 Subject: [PATCH 1/4] Initial plan From fd2cc7459de3b9aab9b2a1ff3bc9dd04e77edf74 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:13:10 +0000 Subject: [PATCH 2/4] Fix tsconfig include dropping files that differ only in base-name case on case-insensitive file systems Co-authored-by: RyanCavanaugh <6685088+RyanCavanaugh@users.noreply.github.com> --- tsc/internal/tsoptions/tsconfigparsing.go | 28 +++++++-- .../tsoptions/tsconfigparsing_test.go | 60 +++++++++++++++++++ 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/tsc/internal/tsoptions/tsconfigparsing.go b/tsc/internal/tsoptions/tsconfigparsing.go index e491d40221679..7f412846a7d2e 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 { @@ -1903,7 +1906,8 @@ func hasFileWithHigherPriorityExtension(file string, extensions [][]string, hasF } // 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 +1923,11 @@ 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) + lowerPriorityPath := keyMapper(lowerPriorityFile) + if existing, ok := wildcardFiles.Get(lowerPriorityPath); ok && existing == lowerPriorityFile { + wildcardFiles.Delete(lowerPriorityPath) + } } } @@ -1993,9 +2000,20 @@ 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 existing, ok := literalFileMap.Get(canonicalFileName); ok { + return existing == fileName + } + if existing, ok := wildcardFileMap.Get(canonicalFileName); ok { + return existing == fileName + } + return false }) { continue } diff --git a/tsc/internal/tsoptions/tsconfigparsing_test.go b/tsc/internal/tsoptions/tsconfigparsing_test.go index 914c62379de42..3cf85214ef624 100644 --- a/tsc/internal/tsoptions/tsconfigparsing_test.go +++ b/tsc/internal/tsoptions/tsconfigparsing_test.go @@ -949,6 +949,66 @@ func TestParseJsonConfigFileContentDefaultsCompileOnSaveToFalse(t *testing.T) { assert.Equal(t, *parsed.CompileOnSave, false) } +// 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()) From b4f9f16f24002f6a364b250aac712955fc9bf1cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:16:06 +0000 Subject: [PATCH 3/4] Extract shared getExactFile helper to reduce duplication Co-authored-by: RyanCavanaugh <6685088+RyanCavanaugh@users.noreply.github.com> --- tsc/internal/tsoptions/tsconfigparsing.go | 28 +++++++++++++++-------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/tsc/internal/tsoptions/tsconfigparsing.go b/tsc/internal/tsoptions/tsconfigparsing.go index 7f412846a7d2e..68c440f479cc8 100644 --- a/tsc/internal/tsoptions/tsconfigparsing.go +++ b/tsc/internal/tsoptions/tsconfigparsing.go @@ -1905,6 +1905,18 @@ 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. 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. @@ -1924,9 +1936,8 @@ func removeWildcardFilesWithLowerPriorityExtension(file string, wildcardFiles *c return } lowerPriorityFile := tspath.ChangeExtension(file, ext) - lowerPriorityPath := keyMapper(lowerPriorityFile) - if existing, ok := wildcardFiles.Get(lowerPriorityPath); ok && existing == lowerPriorityFile { - wildcardFiles.Delete(lowerPriorityPath) + if canonicalFileName, ok := getExactFile(wildcardFiles, keyMapper, lowerPriorityFile); ok { + wildcardFiles.Delete(canonicalFileName) } } } @@ -2006,14 +2017,11 @@ func getFileNamesFromConfigSpecs( // 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) - if existing, ok := literalFileMap.Get(canonicalFileName); ok { - return existing == fileName - } - if existing, ok := wildcardFileMap.Get(canonicalFileName); ok { - return existing == fileName + if _, ok := getExactFile(&literalFileMap, keyMappper, fileName); ok { + return true } - return false + _, ok := getExactFile(&wildcardFileMap, keyMappper, fileName) + return ok }) { continue } From 4a37f5dd5ff605486e60276188314adad452a6b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:22:43 +0000 Subject: [PATCH 4/4] Add direct unit test for getExactFile helper Co-authored-by: RyanCavanaugh <6685088+RyanCavanaugh@users.noreply.github.com> --- tsc/internal/tsoptions/export_test.go | 5 ++ .../tsoptions/tsconfigparsing_test.go | 46 +++++++++++++++++++ 2 files changed, 51 insertions(+) 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_test.go b/tsc/internal/tsoptions/tsconfigparsing_test.go index 3cf85214ef624..50bb9f7ad2fb3 100644 --- a/tsc/internal/tsoptions/tsconfigparsing_test.go +++ b/tsc/internal/tsoptions/tsconfigparsing_test.go @@ -949,6 +949,52 @@ 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