From f76e5a3aa831c41037d95e024bd3d00773e9d5d5 Mon Sep 17 00:00:00 2001 From: marwan562 Date: Sat, 5 Sep 2026 07:39:05 +0300 Subject: [PATCH 1/3] fix(auto-import): don't suggest # imports that only resolve via condition fallback Reverse mapping in tryGetModuleNameFromExportsOrImports mirrored TS resolver fallback across conditions, suggesting specifiers like #utils/summarize/summarize that resolve via default only after node misses. Node picks first matching condition and throws on miss, so such suggestions crash at runtime with ERR_MODULE_NOT_FOUND. Mirror Node first-match semantics: when a runtime-active condition misses, block later conditions. Types-only conditions are ignored at runtime and don't block. Handles nested conditionals without active runtime keys and preserves array fallback. Closes #64171 --- tsc/internal/modulespecifiers/specifiers.go | 44 ++++- .../modulespecifiers/specifiers_test.go | 165 ++++++++++++++++++ 2 files changed, 208 insertions(+), 1 deletion(-) diff --git a/tsc/internal/modulespecifiers/specifiers.go b/tsc/internal/modulespecifiers/specifiers.go index 5418ca371aa3a..8f2183c27cbd7 100644 --- a/tsc/internal/modulespecifiers/specifiers.go +++ b/tsc/internal/modulespecifiers/specifiers.go @@ -1308,7 +1308,15 @@ func tryGetModuleNameFromExportsOrImports( } } case packagejson.JSONValueTypeObject: - // conditional mapping + // conditional mapping. + // Node.js resolves conditionals by picking the first key (in object order) that + // matches the active conditions and stopping there: if that target fails to + // resolve, it throws instead of falling through to the next matching condition + // (except fallback arrays, which do try each element - see case Array above, + // which intentionally still loops). + // The reverse mapping below must mirror that, otherwise auto-import suggests + // specifiers that only resolve in TS (via fallback) but crash at runtime. + // See https://github.com/microsoft/TypeScript/issues/64171. obj := exports.AsObject() for key, value := range obj.Entries() { if key == "default" || slices.Contains(conditions, key) || slices.Contains(conditions, "types") && module.IsApplicableVersionedTypesKey(key) { @@ -1316,6 +1324,22 @@ func tryGetModuleNameFromExportsOrImports( if len(result) > 0 { return result } + // If this key would be tried at runtime (i.e. it is not a types-only + // condition, which Node ignores) but the target file does not match + // its target, Node would stop here and fail. A later matching + // condition (e.g. "default" after "node") would never be reached, + // so the candidate specifier is invalid and must not be suggested. + // Custom conditions from tsconfig are assumed active at runtime + // (per GetConditions), so they also block. + if isRuntimeCondition(key) { + // If the value is itself a conditional object with no active + // runtime key, Node would skip it (return undefined) and try the + // next outer condition, so do not block in that case. + if value.Type == packagejson.JSONValueTypeObject && !hasActiveRuntimeCondition(value, conditions) { + continue + } + return "" + } } } case packagejson.JSONValueTypeNull: @@ -1324,6 +1348,24 @@ func tryGetModuleNameFromExportsOrImports( return "" } +func isRuntimeCondition(key string) bool { + return key != "types" && !module.IsApplicableVersionedTypesKey(key) +} + +func hasActiveRuntimeCondition(exports packagejson.ExportsOrImports, conditions []string) bool { + if exports.Type != packagejson.JSONValueTypeObject { + return false + } + for key := range exports.AsObject().Keys() { + if key == "default" || slices.Contains(conditions, key) { + if isRuntimeCondition(key) { + return true + } + } + } + return false +} + // `importingSourceFile` and `importingSourceFileName`? Why not just use `importingSourceFile.path`? // Because when this is called by the declaration emitter, `importingSourceFile` is the implementation // file, but `importingSourceFileName` and `toFileName` refer to declaration files (the former to the diff --git a/tsc/internal/modulespecifiers/specifiers_test.go b/tsc/internal/modulespecifiers/specifiers_test.go index 35269a9df76c9..35dccced63391 100644 --- a/tsc/internal/modulespecifiers/specifiers_test.go +++ b/tsc/internal/modulespecifiers/specifiers_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/microsoft/TypeScript/tsc/internal/ast" + "github.com/microsoft/TypeScript/tsc/internal/collections" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/module" "github.com/microsoft/TypeScript/tsc/internal/packagejson" @@ -344,4 +345,168 @@ func TestTryGetModuleNameFromExportsOrImports(t *testing.T) { }) } }) + t.Run("with conditional fallback blocked (issue 64171)", func(t *testing.T) { + t.Parallel() + + strExports := func(s string) packagejson.ExportsOrImports { + return packagejson.ExportsOrImports{ + JSONValue: packagejson.JSONValue{ + Type: packagejson.JSONValueTypeString, + Value: s, + }, + } + } + condExports := func(entries ...collections.MapEntry[string, packagejson.ExportsOrImports]) packagejson.ExportsOrImports { + return packagejson.ExportsOrImports{ + JSONValue: packagejson.JSONValue{ + Type: packagejson.JSONValueTypeObject, + Value: collections.NewOrderedMapFromList(entries), + }, + } + } + // "#*": { "node": "./dist/*/index.js", "default": "./dist/*.js" } + conditional := condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "node", Value: strExports("./dist/*/index.js")}, + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/*.js")}, + ) + conditions := []string{"import", "types", "node"} + + tests := []struct { + name string + targetFilePath string + expected string + }{ + { + name: "node condition matches, valid specifier", + targetFilePath: "/pkg/dist/utils/summarize/index.js", + expected: "#utils/summarize", + }, + { + name: "node condition shadows default, invalid specifier blocked", + targetFilePath: "/pkg/dist/utils/summarize/summarize.js", + expected: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := tryGetModuleNameFromExportsOrImports( + &core.CompilerOptions{}, + &mockModuleSpecifierGenerationHost{currentDir: "/pkg", useCaseSensitiveFileNames: true}, + tt.targetFilePath, + "/pkg", + "#*", + conditional, + conditions, + MatchingModePattern, + true, + false, + ) + if result != tt.expected { + t.Errorf("tryGetModuleNameFromExportsOrImports(targetFilePath = %q) = %q, expected %q", tt.targetFilePath, result, tt.expected) + } + }) + } + }) + t.Run("types-only condition does not shadow runtime (issue 64171 follow-up)", func(t *testing.T) { + t.Parallel() + strExports := func(s string) packagejson.ExportsOrImports { + return packagejson.ExportsOrImports{ + JSONValue: packagejson.JSONValue{ + Type: packagejson.JSONValueTypeString, + Value: s, + }, + } + } + conditional := packagejson.ExportsOrImports{ + JSONValue: packagejson.JSONValue{ + Type: packagejson.JSONValueTypeObject, + Value: collections.NewOrderedMapFromList([]collections.MapEntry[string, packagejson.ExportsOrImports]{ + {Key: "types", Value: strExports("./types/*.d.ts")}, + {Key: "default", Value: strExports("./dist/*.js")}, + }), + }, + } + result := tryGetModuleNameFromExportsOrImports( + &core.CompilerOptions{}, + &mockModuleSpecifierGenerationHost{currentDir: "/pkg", useCaseSensitiveFileNames: true}, + "/pkg/dist/foo.js", + "/pkg", + "#*", + conditional, + []string{"import", "types", "node"}, + MatchingModePattern, + true, + false, + ) + if result != "#foo" { + t.Errorf("expected #foo for runtime file when types misses, got %q", result) + } + }) + t.Run("conditional edge cases (issue 64171)", func(t *testing.T) { + t.Parallel() + strExports := func(s string) packagejson.ExportsOrImports { + return packagejson.ExportsOrImports{ + JSONValue: packagejson.JSONValue{ + Type: packagejson.JSONValueTypeString, + Value: s, + }, + } + } + condExports := func(entries ...collections.MapEntry[string, packagejson.ExportsOrImports]) packagejson.ExportsOrImports { + return packagejson.ExportsOrImports{ + JSONValue: packagejson.JSONValue{ + Type: packagejson.JSONValueTypeObject, + Value: collections.NewOrderedMapFromList(entries), + }, + } + } + arrExports := func(elems ...packagejson.ExportsOrImports) packagejson.ExportsOrImports { + return packagejson.ExportsOrImports{ + JSONValue: packagejson.JSONValue{ + Type: packagejson.JSONValueTypeArray, + Value: elems, + }, + } + } + host := &mockModuleSpecifierGenerationHost{currentDir: "/pkg", useCaseSensitiveFileNames: true} + conditions := []string{"import", "types", "node"} + + // Array fallback is allowed at runtime: second element match is valid. + arrayCond := condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "node", Value: arrExports(strExports("./dist/a.js"), strExports("./dist/b.js"))}, + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/c.js")}, + ) + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/b.js", "/pkg", "#a", arrayCond, conditions, MatchingModeExact, true, false); got != "#a" { + t.Errorf("array second-element match should be valid, got %q", got) + } + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/c.js", "/pkg", "#a", arrayCond, conditions, MatchingModeExact, true, false); got != "" { + t.Errorf("array miss under node should block default, got %q", got) + } + + // Nested conditional with no active runtime key should not block outer default. + // Outer node -> inner { import: ... } with CJS conditions (require, no import): inner skipped, outer default valid. + nestedNoMatch := condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "node", Value: condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "import", Value: strExports("./dist/a.js")}, + )}, + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/b.js")}, + ) + cjsConditions := []string{"require", "types", "node"} + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/b.js", "/pkg", "#a", nestedNoMatch, cjsConditions, MatchingModeExact, true, false); got != "#a" { + t.Errorf("nested no-active-key should fallback to outer default, got %q", got) + } + + // Default-first is terminal: later node unreachable. + defaultFirst := condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/a.js")}, + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "node", Value: strExports("./dist/b.js")}, + ) + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/a.js", "/pkg", "#a", defaultFirst, conditions, MatchingModeExact, true, false); got != "#a" { + t.Errorf("default-first match should be valid, got %q", got) + } + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/b.js", "/pkg", "#a", defaultFirst, conditions, MatchingModeExact, true, false); got != "" { + t.Errorf("default-first miss should block later node, got %q", got) + } + }) } From 2b4f515c2b89d626e81dd19b9a0dfd9be0a31a50 Mon Sep 17 00:00:00 2001 From: marwan562 Date: Sat, 5 Sep 2026 07:53:17 +0300 Subject: [PATCH 2/3] fix(auto-import): track terminal vs undefined for conditionals and arrays Address Copilot review: arrays select first valid string target at runtime (no file-existence fallback), and deeper nested conditionals with inactive keys return undefined and should fallback. Switch reverse mapping to tri-state (matched/blocked/skipped) so terminal misses block later fallback while undefined continues. --- tsc/internal/modulespecifiers/specifiers.go | 105 ++++++++++-------- .../modulespecifiers/specifiers_test.go | 38 ++++++- 2 files changed, 92 insertions(+), 51 deletions(-) diff --git a/tsc/internal/modulespecifiers/specifiers.go b/tsc/internal/modulespecifiers/specifiers.go index 8f2183c27cbd7..23de385317a79 100644 --- a/tsc/internal/modulespecifiers/specifiers.go +++ b/tsc/internal/modulespecifiers/specifiers.go @@ -1213,9 +1213,28 @@ func tryGetModuleNameFromExportsOrImports( isImports bool, preferTsExtension bool, ) string { + result, _ := tryGetModuleNameFromExportsOrImportsInner(options, host, targetFilePath, packageDirectory, packageName, exports, conditions, mode, isImports, preferTsExtension) + return result +} + +// Inner returns (specifier, blocked). Blocked means a runtime-active target was +// tried but didn't match the file, so Node would stop here and callers must not +// fall through to later conditions or array elements. +func tryGetModuleNameFromExportsOrImportsInner( + options *core.CompilerOptions, + host ModuleSpecifierGenerationHost, + targetFilePath string, + packageDirectory string, + packageName string, + exports packagejson.ExportsOrImports, + conditions []string, + mode MatchingMode, + isImports bool, + preferTsExtension bool, +) (string, bool) { switch exports.Type { case packagejson.JSONValueTypeNotPresent: - return "" + return "", false case packagejson.JSONValueTypeString: strValue := exports.Value.(string) @@ -1245,68 +1264,77 @@ func tryGetModuleNameFromExportsOrImports( tspath.ComparePaths(targetFilePath, pathOrPattern, compareOpts) == 0 || len(outputFile) > 0 && tspath.ComparePaths(outputFile, pathOrPattern, compareOpts) == 0 || len(declarationFile) > 0 && tspath.ComparePaths(declarationFile, pathOrPattern, compareOpts) == 0 { - return packageName + return packageName, false } case MatchingModeDirectory: if canTryTsExtension && tspath.ContainsPath(targetFilePath, pathOrPattern, compareOpts) { fragment := tspath.GetRelativePathFromDirectory(pathOrPattern, targetFilePath, compareOpts) - return tspath.GetNormalizedAbsolutePath(tspath.CombinePaths(tspath.CombinePaths(packageName, strValue), fragment), "") + return tspath.GetNormalizedAbsolutePath(tspath.CombinePaths(tspath.CombinePaths(packageName, strValue), fragment), ""), false } if len(extensionSwappedTarget) > 0 && tspath.ContainsPath(pathOrPattern, extensionSwappedTarget, compareOpts) { fragment := tspath.GetRelativePathFromDirectory(pathOrPattern, extensionSwappedTarget, compareOpts) - return tspath.GetNormalizedAbsolutePath(tspath.CombinePaths(tspath.CombinePaths(packageName, strValue), fragment), "") + return tspath.GetNormalizedAbsolutePath(tspath.CombinePaths(tspath.CombinePaths(packageName, strValue), fragment), ""), false } if !canTryTsExtension && tspath.ContainsPath(pathOrPattern, targetFilePath, compareOpts) { fragment := tspath.GetRelativePathFromDirectory(pathOrPattern, targetFilePath, compareOpts) - return tspath.GetNormalizedAbsolutePath(tspath.CombinePaths(tspath.CombinePaths(packageName, strValue), fragment), "") + return tspath.GetNormalizedAbsolutePath(tspath.CombinePaths(tspath.CombinePaths(packageName, strValue), fragment), ""), false } if len(outputFile) > 0 && tspath.ContainsPath(pathOrPattern, outputFile, compareOpts) { fragment := tspath.GetRelativePathFromDirectory(pathOrPattern, outputFile, compareOpts) - return tspath.CombinePaths(packageName, fragment) + return tspath.CombinePaths(packageName, fragment), false } if len(declarationFile) > 0 && tspath.ContainsPath(pathOrPattern, declarationFile, compareOpts) { fragment := tspath.GetRelativePathFromDirectory(pathOrPattern, declarationFile, compareOpts) jsExtension := getJSExtensionForFile(declarationFile, options) fragmentWithJsExtension := tspath.ChangeExtension(fragment, jsExtension) - return tspath.CombinePaths(packageName, fragmentWithJsExtension) + return tspath.CombinePaths(packageName, fragmentWithJsExtension), false } case MatchingModePattern: leadingSlice, trailingSlice, _ := strings.Cut(pathOrPattern, "*") caseSensitive := host.UseCaseSensitiveFileNames() if canTryTsExtension && stringutil.HasPrefixAndSuffixWithoutOverlap(targetFilePath, leadingSlice, trailingSlice, caseSensitive) { starReplacement := targetFilePath[len(leadingSlice) : len(targetFilePath)-len(trailingSlice)] - return replaceFirstStar(packageName, starReplacement) + return replaceFirstStar(packageName, starReplacement), false } if len(extensionSwappedTarget) > 0 && stringutil.HasPrefixAndSuffixWithoutOverlap(extensionSwappedTarget, leadingSlice, trailingSlice, caseSensitive) { starReplacement := extensionSwappedTarget[len(leadingSlice) : len(extensionSwappedTarget)-len(trailingSlice)] - return replaceFirstStar(packageName, starReplacement) + return replaceFirstStar(packageName, starReplacement), false } if !canTryTsExtension && stringutil.HasPrefixAndSuffixWithoutOverlap(targetFilePath, leadingSlice, trailingSlice, caseSensitive) { starReplacement := targetFilePath[len(leadingSlice) : len(targetFilePath)-len(trailingSlice)] - return replaceFirstStar(packageName, starReplacement) + return replaceFirstStar(packageName, starReplacement), false } if len(outputFile) > 0 && stringutil.HasPrefixAndSuffixWithoutOverlap(outputFile, leadingSlice, trailingSlice, caseSensitive) { starReplacement := outputFile[len(leadingSlice) : len(outputFile)-len(trailingSlice)] - return replaceFirstStar(packageName, starReplacement) + return replaceFirstStar(packageName, starReplacement), false } if len(declarationFile) > 0 && stringutil.HasPrefixAndSuffixWithoutOverlap(declarationFile, leadingSlice, trailingSlice, caseSensitive) { starReplacement := declarationFile[len(leadingSlice) : len(declarationFile)-len(trailingSlice)] substituted := replaceFirstStar(packageName, starReplacement) jsExtension := module.TryGetJSExtensionForFile(declarationFile, options) if len(jsExtension) > 0 { - return tspath.ChangeFullExtension(substituted, jsExtension) + return tspath.ChangeFullExtension(substituted, jsExtension), false } } } - return "" + // String is an unconditional valid target: if it doesn't match the file, + // Node would still select it and fail, so it's terminal. + return "", true case packagejson.JSONValueTypeArray: + // Arrays are ordered fallbacks for undefined/invalid entries only. A valid + // string target that doesn't match the file still selects that URL at + // runtime and throws on miss, so it blocks later elements. arr := exports.AsArray() for _, e := range arr { - result := tryGetModuleNameFromExportsOrImports(options, host, targetFilePath, packageDirectory, packageName, e, conditions, mode, isImports, preferTsExtension) + result, blocked := tryGetModuleNameFromExportsOrImportsInner(options, host, targetFilePath, packageDirectory, packageName, e, conditions, mode, isImports, preferTsExtension) if len(result) > 0 { - return result + return result, false + } + if blocked { + return "", true } } + return "", false case packagejson.JSONValueTypeObject: // conditional mapping. // Node.js resolves conditionals by picking the first key (in object order) that @@ -1320,52 +1348,35 @@ func tryGetModuleNameFromExportsOrImports( obj := exports.AsObject() for key, value := range obj.Entries() { if key == "default" || slices.Contains(conditions, key) || slices.Contains(conditions, "types") && module.IsApplicableVersionedTypesKey(key) { - result := tryGetModuleNameFromExportsOrImports(options, host, targetFilePath, packageDirectory, packageName, value, conditions, mode, isImports, preferTsExtension) + result, blocked := tryGetModuleNameFromExportsOrImportsInner(options, host, targetFilePath, packageDirectory, packageName, value, conditions, mode, isImports, preferTsExtension) if len(result) > 0 { - return result + return result, false } // If this key would be tried at runtime (i.e. it is not a types-only - // condition, which Node ignores) but the target file does not match - // its target, Node would stop here and fail. A later matching - // condition (e.g. "default" after "node") would never be reached, - // so the candidate specifier is invalid and must not be suggested. - // Custom conditions from tsconfig are assumed active at runtime - // (per GetConditions), so they also block. - if isRuntimeCondition(key) { - // If the value is itself a conditional object with no active - // runtime key, Node would skip it (return undefined) and try the - // next outer condition, so do not block in that case. - if value.Type == packagejson.JSONValueTypeObject && !hasActiveRuntimeCondition(value, conditions) { - continue - } - return "" + // condition, which Node ignores) and its target was terminal + // (tried but didn't match), Node would stop here and fail. A later + // matching condition (e.g. "default" after "node") would never be + // reached, so the candidate is invalid. If the nested value was + // undefined (no active runtime key inside), Node proceeds to the + // next outer condition, so continue. Custom conditions from + // tsconfig are assumed active at runtime (per GetConditions). + if blocked && isRuntimeCondition(key) { + return "", true } } } + return "", false case packagejson.JSONValueTypeNull: - return "" + // Explicit null is terminal at runtime. + return "", true } - return "" + return "", false } func isRuntimeCondition(key string) bool { return key != "types" && !module.IsApplicableVersionedTypesKey(key) } -func hasActiveRuntimeCondition(exports packagejson.ExportsOrImports, conditions []string) bool { - if exports.Type != packagejson.JSONValueTypeObject { - return false - } - for key := range exports.AsObject().Keys() { - if key == "default" || slices.Contains(conditions, key) { - if isRuntimeCondition(key) { - return true - } - } - } - return false -} - // `importingSourceFile` and `importingSourceFileName`? Why not just use `importingSourceFile.path`? // Because when this is called by the declaration emitter, `importingSourceFile` is the implementation // file, but `importingSourceFileName` and `toFileName` refer to declaration files (the former to the diff --git a/tsc/internal/modulespecifiers/specifiers_test.go b/tsc/internal/modulespecifiers/specifiers_test.go index 35dccced63391..b6585c779249c 100644 --- a/tsc/internal/modulespecifiers/specifiers_test.go +++ b/tsc/internal/modulespecifiers/specifiers_test.go @@ -471,19 +471,36 @@ func TestTryGetModuleNameFromExportsOrImports(t *testing.T) { } host := &mockModuleSpecifierGenerationHost{currentDir: "/pkg", useCaseSensitiveFileNames: true} conditions := []string{"import", "types", "node"} + cjsConditions := []string{"require", "types", "node"} - // Array fallback is allowed at runtime: second element match is valid. + // Arrays are not file-existence fallbacks in Node: first valid string wins, + // even if the file is missing. Only undefined/invalid entries fall through. arrayCond := condExports( collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "node", Value: arrExports(strExports("./dist/a.js"), strExports("./dist/b.js"))}, collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/c.js")}, ) - if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/b.js", "/pkg", "#a", arrayCond, conditions, MatchingModeExact, true, false); got != "#a" { - t.Errorf("array second-element match should be valid, got %q", got) + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/a.js", "/pkg", "#a", arrayCond, conditions, MatchingModeExact, true, false); got != "#a" { + t.Errorf("array first-element match should be valid, got %q", got) + } + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/b.js", "/pkg", "#a", arrayCond, conditions, MatchingModeExact, true, false); got != "" { + t.Errorf("array second-element match should be blocked (first valid string wins at runtime), got %q", got) } if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/c.js", "/pkg", "#a", arrayCond, conditions, MatchingModeExact, true, false); got != "" { t.Errorf("array miss under node should block default, got %q", got) } + // Array with undefined first entry falls through: [{ import: ... }] skipped when import inactive. + arrayUndefinedFirst := condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "node", Value: arrExports( + condExports(collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "import", Value: strExports("./dist/a.js")}), + strExports("./dist/b.js"), + )}, + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/c.js")}, + ) + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/b.js", "/pkg", "#a", arrayUndefinedFirst, cjsConditions, MatchingModeExact, true, false); got != "#a" { + t.Errorf("array undefined first entry should fallback to second element, got %q", got) + } + // Nested conditional with no active runtime key should not block outer default. // Outer node -> inner { import: ... } with CJS conditions (require, no import): inner skipped, outer default valid. nestedNoMatch := condExports( @@ -492,11 +509,24 @@ func TestTryGetModuleNameFromExportsOrImports(t *testing.T) { )}, collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/b.js")}, ) - cjsConditions := []string{"require", "types", "node"} if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/b.js", "/pkg", "#a", nestedNoMatch, cjsConditions, MatchingModeExact, true, false); got != "#a" { t.Errorf("nested no-active-key should fallback to outer default, got %q", got) } + // Deeper nesting: { node: { import: { browser: ./a.js } }, default: ./b.js } + // with active node/import but inactive browser -> undefined, fallback to default valid. + deepNested := condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "node", Value: condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "import", Value: condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "browser", Value: strExports("./dist/a.js")}, + )}, + )}, + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/b.js")}, + ) + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/b.js", "/pkg", "#a", deepNested, conditions, MatchingModeExact, true, false); got != "#a" { + t.Errorf("deep nested undefined should fallback to outer default, got %q", got) + } + // Default-first is terminal: later node unreachable. defaultFirst := condExports( collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/a.js")}, From 6cd882eff26c67dc2eeea5724dc1115ec1579cac Mon Sep 17 00:00:00 2001 From: marwan562 Date: Tue, 8 Sep 2026 23:59:20 +0300 Subject: [PATCH 3/3] fix(auto-import): distinguish invalid vs valid targets, preserve types fallback, handle empty/all-invalid arrays Address Copilot review on PR 64177: - String misses now check target validity (same rules as forward resolver): invalid targets skipped in arrays, terminal in conditionals; valid misses stay terminal. - Thread inTypesOnly through conditional/array recursion so types-only subtrees never block, preserving TS declaration fallback that Node ignores at runtime. - Empty and all-invalid arrays are terminal at runtime; only all-undefined arrays fall through. - Add unit cases and Fourslash regression for issue 64171. --- ...PackageJsonImportsInvalidSpecifier_test.go | 44 ++++ tsc/internal/modulespecifiers/specifiers.go | 207 ++++++++++++++---- .../modulespecifiers/specifiers_test.go | 92 ++++++++ 3 files changed, 301 insertions(+), 42 deletions(-) create mode 100644 tsc/internal/fourslash/tests/autoImportPackageJsonImportsInvalidSpecifier_test.go diff --git a/tsc/internal/fourslash/tests/autoImportPackageJsonImportsInvalidSpecifier_test.go b/tsc/internal/fourslash/tests/autoImportPackageJsonImportsInvalidSpecifier_test.go new file mode 100644 index 0000000000000..827e95d03820d --- /dev/null +++ b/tsc/internal/fourslash/tests/autoImportPackageJsonImportsInvalidSpecifier_test.go @@ -0,0 +1,44 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/fourslash" + "github.com/microsoft/TypeScript/tsc/internal/ls/lsutil" + "github.com/microsoft/TypeScript/tsc/internal/testutil" +) + +func TestAutoImportPackageJsonImportsInvalidSpecifier(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /tsconfig.json +{ + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext" + } +} +// @Filename: /package.json +{ + "imports": { + "#*": { + "node": "./dist/*/index.js", + "default": "./dist/*.js" + } + } +} +// @Filename: /dist/utils/summarize/index.ts +export function summarize(): void; +// @Filename: /dist/utils/summarize/summarize.ts +export function summarize(): void; +// @Filename: /src/index.ts +summarize/*a*/` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + // #utils/summarize resolves via the active "node" condition. + // #utils/summarize/summarize would only resolve via "default" fallback after + // "node" misses, which Node never tries at runtime (ERR_MODULE_NOT_FOUND), + // so its # specifier must not be suggested; only a relative fallback remains + // for that file. See https://github.com/microsoft/TypeScript/issues/64171. + f.VerifyImportFixModuleSpecifiers(t, "a", []string{"#utils/summarize", "../dist/utils/summarize/summarize"}, &lsutil.UserPreferences{ImportModuleSpecifierPreference: "non-relative"}) +} diff --git a/tsc/internal/modulespecifiers/specifiers.go b/tsc/internal/modulespecifiers/specifiers.go index 23de385317a79..48ab062c5a768 100644 --- a/tsc/internal/modulespecifiers/specifiers.go +++ b/tsc/internal/modulespecifiers/specifiers.go @@ -1213,13 +1213,39 @@ func tryGetModuleNameFromExportsOrImports( isImports bool, preferTsExtension bool, ) string { - result, _ := tryGetModuleNameFromExportsOrImportsInner(options, host, targetFilePath, packageDirectory, packageName, exports, conditions, mode, isImports, preferTsExtension) + result, _ := tryGetModuleNameFromExportsOrImportsInner(options, host, targetFilePath, packageDirectory, packageName, exports, conditions, mode, isImports, preferTsExtension, false /*inTypesOnly*/) return result } -// Inner returns (specifier, blocked). Blocked means a runtime-active target was +// targetStatus distinguishes how Node would treat a non-matching target, so +// callers know whether later conditions or array elements may still be tried. +// +// - statusMatched: path matched, return the specifier. +// - statusBlocked: a valid runtime target was selected but didn't match the +// file (or null/empty/all-invalid array). Node would stop here and fail, +// so callers must not fall through. +// - statusInvalid: syntactically invalid package target (e.g. missing "./" +// prefix in exports, ".."/"."/"node_modules" segments, number/boolean). +// Node skips these inside fallback arrays but throws inside conditionals, +// so arrays continue (tracking for all-invalid terminal) while conditionals +// with a runtime-active key stop. +// - statusSkipped: undefined (no active condition key, NotPresent). Node +// proceeds to the next condition or array element. +type targetStatus int8 + +const ( + statusSkipped targetStatus = iota + statusInvalid + statusBlocked + statusMatched +) + +// Inner returns (specifier, status). Blocked means a runtime-active target was // tried but didn't match the file, so Node would stop here and callers must not -// fall through to later conditions or array elements. +// fall through to later conditions or array elements. inTypesOnly tracks whether +// the current subtree sits under a types-only condition (types/types@*), which +// Node ignores at runtime: misses there never block, preserving TypeScript's +// declaration fallback (see resolver.go:869-873). func tryGetModuleNameFromExportsOrImportsInner( options *core.CompilerOptions, host ModuleSpecifierGenerationHost, @@ -1231,10 +1257,14 @@ func tryGetModuleNameFromExportsOrImportsInner( mode MatchingMode, isImports bool, preferTsExtension bool, -) (string, bool) { + inTypesOnly bool, +) (string, targetStatus) { switch exports.Type { case packagejson.JSONValueTypeNotPresent: - return "", false + return "", statusSkipped + case packagejson.JSONValueTypeNumber, packagejson.JSONValueTypeBoolean: + // Invalid package targets: skipped in arrays, terminal in conditionals. + return "", statusInvalid case packagejson.JSONValueTypeString: strValue := exports.Value.(string) @@ -1264,77 +1294,118 @@ func tryGetModuleNameFromExportsOrImportsInner( tspath.ComparePaths(targetFilePath, pathOrPattern, compareOpts) == 0 || len(outputFile) > 0 && tspath.ComparePaths(outputFile, pathOrPattern, compareOpts) == 0 || len(declarationFile) > 0 && tspath.ComparePaths(declarationFile, pathOrPattern, compareOpts) == 0 { - return packageName, false + return packageName, statusMatched } case MatchingModeDirectory: if canTryTsExtension && tspath.ContainsPath(targetFilePath, pathOrPattern, compareOpts) { fragment := tspath.GetRelativePathFromDirectory(pathOrPattern, targetFilePath, compareOpts) - return tspath.GetNormalizedAbsolutePath(tspath.CombinePaths(tspath.CombinePaths(packageName, strValue), fragment), ""), false + return tspath.GetNormalizedAbsolutePath(tspath.CombinePaths(tspath.CombinePaths(packageName, strValue), fragment), ""), statusMatched } if len(extensionSwappedTarget) > 0 && tspath.ContainsPath(pathOrPattern, extensionSwappedTarget, compareOpts) { fragment := tspath.GetRelativePathFromDirectory(pathOrPattern, extensionSwappedTarget, compareOpts) - return tspath.GetNormalizedAbsolutePath(tspath.CombinePaths(tspath.CombinePaths(packageName, strValue), fragment), ""), false + return tspath.GetNormalizedAbsolutePath(tspath.CombinePaths(tspath.CombinePaths(packageName, strValue), fragment), ""), statusMatched } if !canTryTsExtension && tspath.ContainsPath(pathOrPattern, targetFilePath, compareOpts) { fragment := tspath.GetRelativePathFromDirectory(pathOrPattern, targetFilePath, compareOpts) - return tspath.GetNormalizedAbsolutePath(tspath.CombinePaths(tspath.CombinePaths(packageName, strValue), fragment), ""), false + return tspath.GetNormalizedAbsolutePath(tspath.CombinePaths(tspath.CombinePaths(packageName, strValue), fragment), ""), statusMatched } if len(outputFile) > 0 && tspath.ContainsPath(pathOrPattern, outputFile, compareOpts) { fragment := tspath.GetRelativePathFromDirectory(pathOrPattern, outputFile, compareOpts) - return tspath.CombinePaths(packageName, fragment), false + return tspath.CombinePaths(packageName, fragment), statusMatched } if len(declarationFile) > 0 && tspath.ContainsPath(pathOrPattern, declarationFile, compareOpts) { fragment := tspath.GetRelativePathFromDirectory(pathOrPattern, declarationFile, compareOpts) jsExtension := getJSExtensionForFile(declarationFile, options) fragmentWithJsExtension := tspath.ChangeExtension(fragment, jsExtension) - return tspath.CombinePaths(packageName, fragmentWithJsExtension), false + return tspath.CombinePaths(packageName, fragmentWithJsExtension), statusMatched } case MatchingModePattern: leadingSlice, trailingSlice, _ := strings.Cut(pathOrPattern, "*") caseSensitive := host.UseCaseSensitiveFileNames() if canTryTsExtension && stringutil.HasPrefixAndSuffixWithoutOverlap(targetFilePath, leadingSlice, trailingSlice, caseSensitive) { starReplacement := targetFilePath[len(leadingSlice) : len(targetFilePath)-len(trailingSlice)] - return replaceFirstStar(packageName, starReplacement), false + return replaceFirstStar(packageName, starReplacement), statusMatched } if len(extensionSwappedTarget) > 0 && stringutil.HasPrefixAndSuffixWithoutOverlap(extensionSwappedTarget, leadingSlice, trailingSlice, caseSensitive) { starReplacement := extensionSwappedTarget[len(leadingSlice) : len(extensionSwappedTarget)-len(trailingSlice)] - return replaceFirstStar(packageName, starReplacement), false + return replaceFirstStar(packageName, starReplacement), statusMatched } if !canTryTsExtension && stringutil.HasPrefixAndSuffixWithoutOverlap(targetFilePath, leadingSlice, trailingSlice, caseSensitive) { starReplacement := targetFilePath[len(leadingSlice) : len(targetFilePath)-len(trailingSlice)] - return replaceFirstStar(packageName, starReplacement), false + return replaceFirstStar(packageName, starReplacement), statusMatched } if len(outputFile) > 0 && stringutil.HasPrefixAndSuffixWithoutOverlap(outputFile, leadingSlice, trailingSlice, caseSensitive) { starReplacement := outputFile[len(leadingSlice) : len(outputFile)-len(trailingSlice)] - return replaceFirstStar(packageName, starReplacement), false + return replaceFirstStar(packageName, starReplacement), statusMatched } if len(declarationFile) > 0 && stringutil.HasPrefixAndSuffixWithoutOverlap(declarationFile, leadingSlice, trailingSlice, caseSensitive) { starReplacement := declarationFile[len(leadingSlice) : len(declarationFile)-len(trailingSlice)] substituted := replaceFirstStar(packageName, starReplacement) jsExtension := module.TryGetJSExtensionForFile(declarationFile, options) if len(jsExtension) > 0 { - return tspath.ChangeFullExtension(substituted, jsExtension), false + return tspath.ChangeFullExtension(substituted, jsExtension), statusMatched } } } - // String is an unconditional valid target: if it doesn't match the file, - // Node would still select it and fail, so it's terminal. - return "", true + // No path match. Under a types-only subtree, never block: TypeScript's + // forward resolver falls through declaration targets (resolver.go:869-873) + // and Node ignores the whole subtree at runtime. + if inTypesOnly { + return "", statusSkipped + } + // Distinguish invalid targets (skipped in arrays, terminal in conditionals) + // from valid targets that Node would select and then fail on (terminal + // everywhere). Mirrors resolver.go:750-794. + if !isValidPackageTarget(strValue, isImports) { + return "", statusInvalid + } + // Valid string target that doesn't match the file: Node would still select + // it and fail (ERR_MODULE_NOT_FOUND), so it's terminal. + return "", statusBlocked case packagejson.JSONValueTypeArray: // Arrays are ordered fallbacks for undefined/invalid entries only. A valid // string target that doesn't match the file still selects that URL at - // runtime and throws on miss, so it blocks later elements. + // runtime and throws on miss, so it blocks later elements. Empty arrays + // are terminal at runtime (like null); only all-undefined arrays fall + // through. Under types-only, always fall through (TS declaration fallback). arr := exports.AsArray() + if len(arr) == 0 { + if inTypesOnly { + return "", statusSkipped + } + return "", statusBlocked + } + sawInvalid := false for _, e := range arr { - result, blocked := tryGetModuleNameFromExportsOrImportsInner(options, host, targetFilePath, packageDirectory, packageName, e, conditions, mode, isImports, preferTsExtension) - if len(result) > 0 { - return result, false + result, status := tryGetModuleNameFromExportsOrImportsInner(options, host, targetFilePath, packageDirectory, packageName, e, conditions, mode, isImports, preferTsExtension, inTypesOnly) + if status == statusMatched { + return result, statusMatched } - if blocked { - return "", true + switch status { + case statusBlocked: + // Valid miss (or null/empty/all-invalid nested): terminal at + // runtime, but swallowed under types-only to preserve TS fallback. + if inTypesOnly { + continue + } + return "", statusBlocked + case statusInvalid: + // Node skips invalid entries inside arrays and only throws if + // every entry is invalid. Track and continue. + sawInvalid = true + case statusSkipped: + // Undefined (e.g. nested conditional with no active key): try next. } } - return "", false + if inTypesOnly { + return "", statusSkipped + } + if sawInvalid { + // All entries were invalid (any valid miss would have returned blocked + // above): Node throws the last invalid-target error, so terminal. + return "", statusBlocked + } + return "", statusSkipped case packagejson.JSONValueTypeObject: // conditional mapping. // Node.js resolves conditionals by picking the first key (in object order) that @@ -1348,35 +1419,87 @@ func tryGetModuleNameFromExportsOrImportsInner( obj := exports.AsObject() for key, value := range obj.Entries() { if key == "default" || slices.Contains(conditions, key) || slices.Contains(conditions, "types") && module.IsApplicableVersionedTypesKey(key) { - result, blocked := tryGetModuleNameFromExportsOrImportsInner(options, host, targetFilePath, packageDirectory, packageName, value, conditions, mode, isImports, preferTsExtension) - if len(result) > 0 { - return result, false + childInTypes := inTypesOnly || !isRuntimeCondition(key) + result, status := tryGetModuleNameFromExportsOrImportsInner(options, host, targetFilePath, packageDirectory, packageName, value, conditions, mode, isImports, preferTsExtension, childInTypes) + if status == statusMatched { + return result, statusMatched } // If this key would be tried at runtime (i.e. it is not a types-only - // condition, which Node ignores) and its target was terminal - // (tried but didn't match), Node would stop here and fail. A later - // matching condition (e.g. "default" after "node") would never be - // reached, so the candidate is invalid. If the nested value was - // undefined (no active runtime key inside), Node proceeds to the - // next outer condition, so continue. Custom conditions from - // tsconfig are assumed active at runtime (per GetConditions). - if blocked && isRuntimeCondition(key) { - return "", true + // condition, which Node ignores) Node would stop here on both valid + // misses and invalid targets: a later matching condition (e.g. + // "default" after "node") would never be reached, so the candidate + // is invalid. If the nested value was undefined (no active runtime + // key inside), Node proceeds to the next outer condition, so + // continue. Custom conditions from tsconfig are assumed active at + // runtime (per GetConditions). Under types-only, swallow everything + // (childInTypes already true) to preserve TS declaration fallback. + if inTypesOnly { + continue + } + if status == statusBlocked && isRuntimeCondition(key) { + return "", statusBlocked + } + if status == statusInvalid && isRuntimeCondition(key) { + // Invalid target inside a conditional throws at runtime instead + // of falling through. Propagate as invalid so an enclosing + // array can still catch it, while an enclosing conditional will + // stop (see above). Top-level callers treat any non-match as "". + return "", statusInvalid } } } - return "", false + return "", statusSkipped case packagejson.JSONValueTypeNull: - // Explicit null is terminal at runtime. - return "", true + // Explicit null is terminal at runtime (ERR_PACKAGE_PATH_NOT_EXPORTED), + // but swallowed under types-only since Node ignores that subtree. + if inTypesOnly { + return "", statusSkipped + } + return "", statusBlocked } - return "", false + return "", statusSkipped } func isRuntimeCondition(key string) bool { return key != "types" && !module.IsApplicableVersionedTypesKey(key) } +// isValidPackageTarget mirrors the target-validity rules of the forward resolver +// (resolver.go:loadModuleFromTargetExportOrImport) without filesystem probing: it +// reports whether Node would select the target URL (and then fail if the file is +// missing) versus skipping it as an invalid package target. +// +// - exports targets must start with "./" (resolver.go:750,777-780). +// - imports targets may additionally be bare specifiers (e.g. "lodash"), which +// are delegated to node-like resolution (resolver.go:751-775) and count as valid. +// - relative targets with ".."/"."/"node_modules" after the first segment are +// invalid (resolver.go:789-794). +func isValidPackageTarget(target string, isImports bool) bool { + if len(target) == 0 { + return false + } + if !strings.HasPrefix(target, "./") { + if isImports && !strings.HasPrefix(target, "../") && !strings.HasPrefix(target, "/") && !tspath.IsRootedDiskPath(target) { + // Bare specifier for imports (e.g. "dep-native"): valid, resolved via + // node-like lookup. A non-matching file still means Node selected this + // target, so callers must block later fallback. + return true + } + return false + } + var parts []string + if tspath.PathIsRelative(target) { + parts = tspath.GetPathComponents(target, "")[1:] + } else { + parts = tspath.GetPathComponents(target, "") + } + if len(parts) <= 1 { + return true + } + partsAfterFirst := parts[1:] + return !slices.Contains(partsAfterFirst, "..") && !slices.Contains(partsAfterFirst, ".") && !slices.Contains(partsAfterFirst, "node_modules") +} + // `importingSourceFile` and `importingSourceFileName`? Why not just use `importingSourceFile.path`? // Because when this is called by the declaration emitter, `importingSourceFile` is the implementation // file, but `importingSourceFileName` and `toFileName` refer to declaration files (the former to the diff --git a/tsc/internal/modulespecifiers/specifiers_test.go b/tsc/internal/modulespecifiers/specifiers_test.go index b6585c779249c..f1fd9035806c7 100644 --- a/tsc/internal/modulespecifiers/specifiers_test.go +++ b/tsc/internal/modulespecifiers/specifiers_test.go @@ -539,4 +539,96 @@ func TestTryGetModuleNameFromExportsOrImports(t *testing.T) { t.Errorf("default-first miss should block later node, got %q", got) } }) + t.Run("copilot review follow-up: invalid vs valid, types arrays, empty/all-invalid (issue 64171)", func(t *testing.T) { + t.Parallel() + strExports := func(s string) packagejson.ExportsOrImports { + return packagejson.ExportsOrImports{ + JSONValue: packagejson.JSONValue{ + Type: packagejson.JSONValueTypeString, + Value: s, + }, + } + } + condExports := func(entries ...collections.MapEntry[string, packagejson.ExportsOrImports]) packagejson.ExportsOrImports { + return packagejson.ExportsOrImports{ + JSONValue: packagejson.JSONValue{ + Type: packagejson.JSONValueTypeObject, + Value: collections.NewOrderedMapFromList(entries), + }, + } + } + arrExports := func(elems ...packagejson.ExportsOrImports) packagejson.ExportsOrImports { + return packagejson.ExportsOrImports{ + JSONValue: packagejson.JSONValue{ + Type: packagejson.JSONValueTypeArray, + Value: elems, + }, + } + } + nullExports := packagejson.ExportsOrImports{ + JSONValue: packagejson.JSONValue{Type: packagejson.JSONValueTypeNull}, + } + host := &mockModuleSpecifierGenerationHost{currentDir: "/pkg", useCaseSensitiveFileNames: true} + conditions := []string{"import", "types", "node"} + + // Invalid strings are skipped inside arrays (exports context: no "./" prefix). + // ["invalid", "./dist/b.js"] targeting b.js must match via the second element. + invalidSkipped := condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "node", Value: arrExports(strExports("invalid"), strExports("./dist/b.js"))}, + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/c.js")}, + ) + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/b.js", "/pkg", "#a", invalidSkipped, conditions, MatchingModeExact, false, false); got != "#a" { + t.Errorf("array invalid first entry should fallback to valid second element, got %q", got) + } + // Same array targeting c.js: second element is a valid miss, so it blocks default. + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/c.js", "/pkg", "#a", invalidSkipped, conditions, MatchingModeExact, false, false); got != "" { + t.Errorf("array valid miss should block default even after invalid skip, got %q", got) + } + + // Types-only arrays preserve TS fallback: first declaration miss falls through. + typesArray := condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "types", Value: arrExports(strExports("./missing.d.ts"), strExports("./index.d.ts"))}, + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/index.js")}, + ) + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/index.d.ts", "/pkg", "#a", typesArray, conditions, MatchingModeExact, true, false); got != "#a" { + t.Errorf("types array second-element match should be valid, got %q", got) + } + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/index.js", "/pkg", "#a", typesArray, conditions, MatchingModeExact, true, false); got != "#a" { + t.Errorf("types miss should not block default, got %q", got) + } + + // Empty array is terminal at runtime (like null), so default is unreachable. + emptyArray := condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "node", Value: arrExports()}, + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/index.js")}, + ) + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/index.js", "/pkg", "#a", emptyArray, conditions, MatchingModeExact, false, false); got != "" { + t.Errorf("empty array under node should block default, got %q", got) + } + + // All-invalid array is terminal at runtime: Node throws last invalid-target error. + allInvalid := condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "node", Value: arrExports(strExports("../evil"), strExports("not-relative"))}, + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/index.js")}, + ) + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/index.js", "/pkg", "#a", allInvalid, conditions, MatchingModeExact, false, false); got != "" { + t.Errorf("all-invalid array under node should block default, got %q", got) + } + + // Explicit null is terminal at runtime but swallowed under types-only. + nullBlocked := condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "node", Value: nullExports}, + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/b.js")}, + ) + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/b.js", "/pkg", "#a", nullBlocked, conditions, MatchingModeExact, false, false); got != "" { + t.Errorf("null under node should block default, got %q", got) + } + nullUnderTypes := condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "types", Value: nullExports}, + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/b.js")}, + ) + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/b.js", "/pkg", "#a", nullUnderTypes, conditions, MatchingModeExact, false, false); got != "#a" { + t.Errorf("null under types should not block default, got %q", got) + } + }) }