diff --git a/tsc/internal/fourslash/tests/autoImportPackageJsonExportsShadowedCondition1_test.go b/tsc/internal/fourslash/tests/autoImportPackageJsonExportsShadowedCondition1_test.go new file mode 100644 index 0000000000000..ec8834f179a18 --- /dev/null +++ b/tsc/internal/fourslash/tests/autoImportPackageJsonExportsShadowedCondition1_test.go @@ -0,0 +1,45 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/fourslash" + "github.com/microsoft/TypeScript/tsc/internal/testutil" +) + +// "pkg" resolves through the active "node" condition to dist/node.d.ts, so a symbol that only +// exists in the shadowed "default" target (dist/index.d.ts) can't be imported from "pkg". +func TestAutoImportPackageJsonExportsShadowedCondition1(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @module: node18 +// @Filename: /node_modules/pkg/package.json +{ + "name": "pkg", + "version": "1.0.0", + "exports": { + ".": { + "node": "./dist/node.js", + "default": "./dist/index.js" + } + } +} +// @Filename: /node_modules/pkg/dist/node.d.ts +export declare const fromNode: number; +// @Filename: /node_modules/pkg/dist/index.d.ts +export declare const fromDefault: number; +// @Filename: /package.json +{ + "dependencies": { + "pkg": "*" + } +} +// @Filename: /a.ts +fromNode/*1*/; +// @Filename: /b.ts +fromDefault/*2*/;` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyImportFixModuleSpecifiers(t, "1", []string{"pkg"}, nil /*preferences*/) + f.VerifyImportFixModuleSpecifiers(t, "2", []string{}, nil /*preferences*/) +} diff --git a/tsc/internal/fourslash/tests/autoImportPackageJsonExportsShadowedCondition2_test.go b/tsc/internal/fourslash/tests/autoImportPackageJsonExportsShadowedCondition2_test.go new file mode 100644 index 0000000000000..430cc9fb52417 --- /dev/null +++ b/tsc/internal/fourslash/tests/autoImportPackageJsonExportsShadowedCondition2_test.go @@ -0,0 +1,41 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/fourslash" + "github.com/microsoft/TypeScript/tsc/internal/testutil" +) + +// "types" is not a runtime condition, so the runtime conditions listed before it must not shadow +// it: the declaration file it names is exactly what TypeScript resolves "pkg" to. +func TestAutoImportPackageJsonExportsShadowedCondition2(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @module: node18 +// @Filename: /node_modules/pkg/package.json +{ + "name": "pkg", + "version": "1.0.0", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + } +} +// @Filename: /node_modules/pkg/dist/index.d.ts +export declare const dep: number; +// @Filename: /package.json +{ + "dependencies": { + "pkg": "*" + } +} +// @Filename: /index.ts +dep/**/` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyImportFixModuleSpecifiers(t, "", []string{"pkg"}, nil /*preferences*/) +} diff --git a/tsc/internal/fourslash/tests/autoImportPackageJsonExportsShadowedCondition3_test.go b/tsc/internal/fourslash/tests/autoImportPackageJsonExportsShadowedCondition3_test.go new file mode 100644 index 0000000000000..ddf9b4a05517e --- /dev/null +++ b/tsc/internal/fourslash/tests/autoImportPackageJsonExportsShadowedCondition3_test.go @@ -0,0 +1,43 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/fourslash" + "github.com/microsoft/TypeScript/tsc/internal/testutil" +) + +// Same package shape as ShadowedCondition2, imported from an ES module. The "import" target yields no +// declaration file, so resolution falls through to "types"; the "import" condition listed earlier must +// not be treated as shadowing it. +func TestAutoImportPackageJsonExportsShadowedCondition3(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @module: node18 +// @Filename: /node_modules/pkg/package.json +{ + "name": "pkg", + "version": "1.0.0", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + } +} +// @Filename: /node_modules/pkg/dist/index.d.ts +export declare const dep: number; +// @Filename: /package.json +{ + "type": "module", + "dependencies": { + "pkg": "*" + } +} +// @Filename: /index.ts +dep/**/` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyImportFixModuleSpecifiers(t, "", []string{"pkg"}, nil /*preferences*/) +} diff --git a/tsc/internal/fourslash/tests/autoImportPackageJsonImportsShadowedCondition1_test.go b/tsc/internal/fourslash/tests/autoImportPackageJsonImportsShadowedCondition1_test.go new file mode 100644 index 0000000000000..847c14c60504b --- /dev/null +++ b/tsc/internal/fourslash/tests/autoImportPackageJsonImportsShadowedCondition1_test.go @@ -0,0 +1,37 @@ +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" +) + +// The "node" condition is active and is listed first, so at runtime "#utils/summarize/summarize" +// resolves to ./src/utils/summarize/summarize/index.ts, which doesn't exist. The "default" target +// that would have matched is never consulted, so the specifier must not be offered. +func TestAutoImportPackageJsonImportsShadowedCondition1(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @module: nodenext +// @Filename: /package.json +{ + "type": "module", + "imports": { + "#*": { + "node": "./src/*/index.ts", + "default": "./src/*.ts" + } + } +} +// @Filename: /src/utils/summarize/index.ts +export {}; +// @Filename: /src/utils/summarize/summarize.ts +export function summarize(name: string): any; +// @Filename: /src/index.ts +summarize/**/` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyImportFixModuleSpecifiers(t, "", []string{"./utils/summarize/summarize.js"}, &lsutil.UserPreferences{ImportModuleSpecifierPreference: "non-relative"}) +} diff --git a/tsc/internal/fourslash/tests/autoImportPackageJsonImportsShadowedCondition2_test.go b/tsc/internal/fourslash/tests/autoImportPackageJsonImportsShadowedCondition2_test.go new file mode 100644 index 0000000000000..93ed6fa3b8458 --- /dev/null +++ b/tsc/internal/fourslash/tests/autoImportPackageJsonImportsShadowedCondition2_test.go @@ -0,0 +1,34 @@ +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" +) + +// Same mapping as ShadowedCondition1, but the target is the file the active "node" condition +// actually selects, so the "#" specifier is valid and must still be offered. +func TestAutoImportPackageJsonImportsShadowedCondition2(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @module: nodenext +// @Filename: /package.json +{ + "type": "module", + "imports": { + "#*": { + "node": "./src/*/index.ts", + "default": "./src/*.ts" + } + } +} +// @Filename: /src/utils/summarize/index.ts +export function summarize(name: string): any; +// @Filename: /src/index.ts +summarize/**/` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyImportFixModuleSpecifiers(t, "", []string{"#utils/summarize"}, &lsutil.UserPreferences{ImportModuleSpecifierPreference: "non-relative"}) +} diff --git a/tsc/internal/fourslash/tests/autoImportPackageJsonImportsShadowedCondition3_test.go b/tsc/internal/fourslash/tests/autoImportPackageJsonImportsShadowedCondition3_test.go new file mode 100644 index 0000000000000..66a364e4be6a9 --- /dev/null +++ b/tsc/internal/fourslash/tests/autoImportPackageJsonImportsShadowedCondition3_test.go @@ -0,0 +1,36 @@ +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" +) + +// A custom condition shadows "default" in exactly the same way the built-in "node" condition does. +func TestAutoImportPackageJsonImportsShadowedCondition3(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @module: esnext +// @moduleResolution: bundler +// @customConditions: custom +// @Filename: /package.json +{ + "imports": { + "#*": { + "custom": "./src/*/index.ts", + "default": "./src/*.ts" + } + } +} +// @Filename: /src/utils/summarize/index.ts +export {}; +// @Filename: /src/utils/summarize/summarize.ts +export function summarize(name: string): any; +// @Filename: /src/index.ts +summarize/**/` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyImportFixModuleSpecifiers(t, "", []string{"./utils/summarize/summarize"}, &lsutil.UserPreferences{ImportModuleSpecifierPreference: "non-relative"}) +} diff --git a/tsc/internal/fourslash/tests/autoImportPackageJsonImportsShadowedCondition4_test.go b/tsc/internal/fourslash/tests/autoImportPackageJsonImportsShadowedCondition4_test.go new file mode 100644 index 0000000000000..c20cd13f77246 --- /dev/null +++ b/tsc/internal/fourslash/tests/autoImportPackageJsonImportsShadowedCondition4_test.go @@ -0,0 +1,34 @@ +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" +) + +// A null target for the active condition blocks the specifier outright at runtime, so the +// "default" target after it can't rescue the "#" specifier. +func TestAutoImportPackageJsonImportsShadowedCondition4(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @module: nodenext +// @Filename: /package.json +{ + "type": "module", + "imports": { + "#*": { + "node": null, + "default": "./src/*.ts" + } + } +} +// @Filename: /src/utils/summarize.ts +export function summarize(name: string): any; +// @Filename: /src/index.ts +summarize/**/` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyImportFixModuleSpecifiers(t, "", []string{"./utils/summarize.js"}, &lsutil.UserPreferences{ImportModuleSpecifierPreference: "non-relative"}) +} diff --git a/tsc/internal/module/resolver.go b/tsc/internal/module/resolver.go index 2363435bb2207..20301690ced93 100644 --- a/tsc/internal/module/resolver.go +++ b/tsc/internal/module/resolver.go @@ -2310,25 +2310,34 @@ func (r *resolutionState) loadEntrypointsFromExportMap( continue } - conditionAlwaysMatches := condition == "default" || condition == "types" || IsApplicableVersionedTypesKey(condition) + isTypesCondition := condition == "types" || IsApplicableVersionedTypesKey(condition) + conditionAlwaysMatches := condition == "default" || isTypesCondition newIncludeConditions := includeConditions if !conditionAlwaysMatches { newIncludeConditions = includeConditions.Clone() - excludeConditions = excludeConditions.Clone() if newIncludeConditions == nil { newIncludeConditions = &collections.Set[string]{} } newIncludeConditions.Add(condition) + } + // A resolver commits to the first of the preceding conditions it has, so this target is only + // reachable by a resolver that has none of them. That applies to "default" as much as to any + // named condition. The "types" conditions don't exist at runtime, so a resolver that has an + // earlier condition still falls through to a "types" target when the earlier one yields no + // declaration file. + newExcludeConditions := excludeConditions + if !isTypesCondition && len(prevConditions) > 0 { + newExcludeConditions = excludeConditions.Clone() + if newExcludeConditions == nil { + newExcludeConditions = &collections.Set[string]{} + } for _, prevCondition := range prevConditions { - if excludeConditions == nil { - excludeConditions = &collections.Set[string]{} - } - excludeConditions.Add(prevCondition) + newExcludeConditions.Add(prevCondition) } } prevConditions = append(prevConditions, condition) - loadEntrypointsFromTargetExports(subpath, newIncludeConditions, excludeConditions, export) + loadEntrypointsFromTargetExports(subpath, newIncludeConditions, newExcludeConditions, export) if conditionAlwaysMatches { break } diff --git a/tsc/internal/modulespecifiers/specifiers.go b/tsc/internal/modulespecifiers/specifiers.go index 5418ca371aa3a..c37c81b46bb1b 100644 --- a/tsc/internal/modulespecifiers/specifiers.go +++ b/tsc/internal/modulespecifiers/specifiers.go @@ -1308,14 +1308,29 @@ func tryGetModuleNameFromExportsOrImports( } } case packagejson.JSONValueTypeObject: - // conditional mapping + // Conditional mapping. A runtime resolver walks the keys in order and commits to the first one + // whose condition is active: once that key names a usable target, resolution never falls through + // to a later condition, even when the file the target names doesn't exist. A specifier produced + // from a later runtime condition would therefore only resolve under a condition that can never be + // selected, so once a runtime condition has captured resolution, skip the runtime conditions after + // it. Type-only conditions ("types", "types@>=x") are invisible to a runtime resolver: they are + // never shadowed, and never shadow anything themselves. obj := exports.AsObject() + runtimeCaptured := false 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) - if len(result) > 0 { - return result - } + if !conditionMatches(key, conditions) { + continue + } + isRuntime := isRuntimeCondition(key) + if isRuntime && runtimeCaptured { + continue + } + result := tryGetModuleNameFromExportsOrImports(options, host, targetFilePath, packageDirectory, packageName, value, conditions, mode, isImports, preferTsExtension) + if len(result) > 0 { + return result + } + if isRuntime && targetCapturesResolution(value, conditions, isImports) { + runtimeCaptured = true } } case packagejson.JSONValueTypeNull: @@ -1324,6 +1339,59 @@ func tryGetModuleNameFromExportsOrImports( return "" } +// conditionMatches mirrors the resolver's condition matching: "default" always applies, and versioned +// "types@..." keys only apply when the "types" condition itself is in play. +func conditionMatches(key string, conditions []string) bool { + return key == "default" || + slices.Contains(conditions, key) || + slices.Contains(conditions, "types") && module.IsApplicableVersionedTypesKey(key) +} + +// isRuntimeCondition reports whether a condition key is one a runtime resolver can see. The "types" +// conditions are TypeScript-only, so a runtime resolver walks straight past them. +func isRuntimeCondition(key string) bool { + return key != "types" && !module.IsApplicableVersionedTypesKey(key) +} + +// targetCapturesResolution reports whether a runtime resolver that reached `target` would commit to it +// rather than continue on to the next condition or array element. Any syntactically valid target string +// captures resolution, whether or not the file it names exists, and so does a `null` target, which +// blocks the specifier outright; only invalid target strings, empty arrays, and objects with no usable +// condition let the search continue. +func targetCapturesResolution(target packagejson.ExportsOrImports, conditions []string, isImports bool) bool { + switch target.Type { + case packagejson.JSONValueTypeString: + str, _ := target.Value.(string) + return isValidRuntimeTarget(str, isImports) + case packagejson.JSONValueTypeNull: + return true + case packagejson.JSONValueTypeArray: + return core.Some(target.AsArray(), func(elem packagejson.ExportsOrImports) bool { + return targetCapturesResolution(elem, conditions, isImports) + }) + case packagejson.JSONValueTypeObject: + for key, value := range target.AsObject().Entries() { + if isRuntimeCondition(key) && conditionMatches(key, conditions) && targetCapturesResolution(value, conditions, isImports) { + return true + } + } + } + return false +} + +// isValidRuntimeTarget mirrors the target validation in the resolver: an invalid target is skipped +// rather than committed to, so it can't shadow a later condition. +func isValidRuntimeTarget(target string, isImports bool) bool { + if strings.HasPrefix(target, "./") { + return true + } + // An "imports" target may also be a bare specifier, which resolves like any other module name. + return isImports && + !strings.HasPrefix(target, "../") && + !strings.HasPrefix(target, "/") && + !tspath.IsRootedDiskPath(target) +} + // `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..eee55df30cd2d 100644 --- a/tsc/internal/modulespecifiers/specifiers_test.go +++ b/tsc/internal/modulespecifiers/specifiers_test.go @@ -5,6 +5,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/ast" "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/json" "github.com/microsoft/TypeScript/tsc/internal/module" "github.com/microsoft/TypeScript/tsc/internal/packagejson" "github.com/microsoft/TypeScript/tsc/internal/symlinks" @@ -345,3 +346,43 @@ func TestTryGetModuleNameFromExportsOrImports(t *testing.T) { } }) } + +func TestTargetCapturesResolution(t *testing.T) { + t.Parallel() + conditions := []string{"import", "types", "node"} + tests := []struct { + name string + target string + isImports bool + want bool + }{ + {name: "relative string", target: `"./dist/index.js"`, want: true}, + {name: "null blocks the specifier", target: `null`, want: true}, + {name: "bare specifier is invalid in exports", target: `"other-pkg"`, want: false}, + {name: "bare specifier is valid in imports", target: `"other-pkg"`, isImports: true, want: true}, + {name: "parent-relative path is invalid in imports", target: `"../other.js"`, isImports: true, want: false}, + {name: "empty array", target: `[]`, want: false}, + {name: "array with a usable element", target: `["other-pkg", "./a.js"]`, want: true}, + {name: "array with only invalid elements", target: `["other-pkg"]`, want: false}, + {name: "object with an active runtime condition", target: `{"node": "./a.js"}`, want: true}, + {name: "object with default", target: `{"browser": "./a.js", "default": "./b.js"}`, want: true}, + {name: "object with only inactive conditions", target: `{"require": "./a.js", "browser": "./b.js"}`, want: false}, + {name: "object with only a types condition", target: `{"types": "./a.d.ts"}`, want: false}, + {name: "nested object with an active branch", target: `{"node": {"import": "./a.mjs"}}`, want: true}, + {name: "nested object without an active branch", target: `{"node": {"require": "./a.cjs"}}`, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var parsed struct { + Exports packagejson.ExportsOrImports `json:"exports"` + } + if err := json.Unmarshal([]byte(`{"exports": `+tt.target+`}`), &parsed); err != nil { + t.Fatal(err) + } + if got := targetCapturesResolution(parsed.Exports, conditions, tt.isImports); got != tt.want { + t.Errorf("targetCapturesResolution(%s, isImports=%v) = %v, want %v", tt.target, tt.isImports, got, tt.want) + } + }) + } +}