Skip to content

Commit ce8b744

Browse files
Resolve relative content mapper packages, always resolve from the config that declares the mapper (#65105)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: andrewbranch <3277153+andrewbranch@users.noreply.github.com>
1 parent b5f1eff commit ce8b744

7 files changed

Lines changed: 262 additions & 25 deletions

File tree

‎tsc/internal/module/resolver.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,14 @@ func (r *DefaultResolver) ResolvePackageDirectory(moduleName string, containingF
538538
compilerOptions := GetCompilerOptionsWithRedirect(r.compilerOptions, redirectedReference)
539539
containingDirectory := containingFile.Directory()
540540
state := newResolutionState(moduleName, containingDirectory, false /*isTypeReferenceDirective*/, resolutionMode, compilerOptions, redirectedReference, r, nil)
541+
if tspath.IsExternalModuleNameRelative(moduleName) {
542+
packageDirectory := containingDirectory.ResolveDirectory(moduleName)
543+
if r.fs.DirectoryExists(packageDirectory) {
544+
originalPath, resolvedPath := state.getOriginalAndResolvedFileName(tspath.RootedFilePathFromPath(packageDirectory.AsPath()))
545+
return state.createResolvedModuleHandlingSymlink(&resolved{path: resolvedPath, originalPath: originalPath})
546+
}
547+
return nil
548+
}
541549
state.resolvePackageDirectoryOnly = true
542550
if result := state.loadModuleFromNearestNodeModulesDirectory(false /*typesScopeOnly*/); result != nil && result.path != "" {
543551
return state.createResolvedModuleHandlingSymlink(result)

‎tsc/internal/project/configfileregistrybuilder.go‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -799,12 +799,18 @@ func (c *configFileRegistryBuilder) IsContentMapperPackageAllowed(packageDirecto
799799
if c.sessionOptions.InitialWorkspaceRoots == nil {
800800
return true
801801
}
802+
caseSensitivity := c.fs.CaseSensitivity()
803+
if slices.ContainsFunc(c.sessionOptions.InitialWorkspaceRoots, func(root tspath.RootedDirectoryPath) bool {
804+
return caseSensitivity.ContainsPath(root, packageDirectory.AsPath())
805+
}) {
806+
return true
807+
}
802808
_, nodeModulesPath, ok := tspath.RootedFilePathFromPath(packageDirectory.AsPath()).SplitAtLastComponent("node_modules")
803809
if !ok {
804810
return false
805811
}
806812
return slices.ContainsFunc(c.sessionOptions.InitialWorkspaceRoots, func(root tspath.RootedDirectoryPath) bool {
807-
return c.fs.CaseSensitivity().ContainsPath(root, nodeModulesPath.AsPath())
813+
return caseSensitivity.ContainsPath(root, nodeModulesPath.AsPath())
808814
})
809815
}
810816

‎tsc/internal/project/contentmapper_test.go‎

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ func TestContentMapperPackageMustBeInWorkspace(t *testing.T) {
303303
name string
304304
projectDirectory string
305305
workspaceRoots []string
306+
mapperPackage string
306307
mapperPath string
307308
mapperTarget string
308309
allowed bool
@@ -314,6 +315,30 @@ func TestContentMapperPackageMustBeInWorkspace(t *testing.T) {
314315
mapperPath: "/home/trusted/node_modules/mapper/package.json",
315316
allowed: true,
316317
},
318+
{
319+
name: "directory mapper in workspace",
320+
projectDirectory: "/home/trusted",
321+
workspaceRoots: []string{"/home/trusted"},
322+
mapperPackage: "./mapper",
323+
mapperPath: "/home/trusted/mapper/package.json",
324+
allowed: true,
325+
},
326+
{
327+
name: "workspace directory mapper symlinked outside",
328+
projectDirectory: "/home/trusted",
329+
workspaceRoots: []string{"/home/trusted"},
330+
mapperPackage: "./mapper",
331+
mapperPath: "/home/trusted/mapper",
332+
mapperTarget: "/home/outside/mapper",
333+
allowed: true,
334+
},
335+
{
336+
name: "directory mapper outside workspace",
337+
projectDirectory: "/home/untrusted",
338+
workspaceRoots: []string{"/home/trusted"},
339+
mapperPackage: "./mapper",
340+
mapperPath: "/home/untrusted/mapper/package.json",
341+
},
317342
{
318343
name: "loose file outside workspace",
319344
projectDirectory: "/home/untrusted",
@@ -357,10 +382,14 @@ func TestContentMapperPackageMustBeInWorkspace(t *testing.T) {
357382
configPath := test.projectDirectory + "/tsconfig.json"
358383
mainPath := test.projectDirectory + "/main.ts"
359384
boxPath := test.projectDirectory + "/app.box"
385+
mapperPackage := test.mapperPackage
386+
if mapperPackage == "" {
387+
mapperPackage = "mapper"
388+
}
360389
files := map[string]any{
361390
configPath: `{
362391
"compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler" },
363-
"contentMappers": [ { "package": "mapper", "extensions": [".box"] } ]
392+
"contentMappers": [ { "package": "` + mapperPackage + `", "extensions": [".box"] } ]
364393
}`,
365394
mainPath: "import { version } from \"./app.box\";\nexport { version };\n",
366395
boxPath: "export const version = #{target};\n",
@@ -491,6 +520,65 @@ func TestContentMapperPackageManifestChangeReloadsConfig(t *testing.T) {
491520
assert.Assert(t, strings.Contains(boxFile.Text(), "export const version = 7;"), "expected fixed mapper manifest to be reloaded: %q", boxFile.Text())
492521
}
493522

523+
func TestRelativeSymlinkedContentMapperPackageManifestChangeReloadsConfig(t *testing.T) {
524+
t.Parallel()
525+
const packageJsonPath = "/home/mapper/package.json"
526+
files := map[string]any{
527+
"/home/project/tsconfig.json": `{
528+
"compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler" },
529+
"contentMappers": [{ "package": "./mapper", "extensions": [".box"] }]
530+
}`,
531+
"/home/project/mapper": vfstest.Symlink("/home/mapper"),
532+
packageJsonPath: `{
533+
"name": "mapper",
534+
"version": "1.0.0",
535+
"typescript": { "contentMapper": { "exec": ["compiler-test-mapper"] } }
536+
}`,
537+
"/home/project/app.box": "export const version = #{target};\n",
538+
"/home/project/main.ts": "import { version } from \"./app.box\";\n",
539+
}
540+
caps := &lsproto.ResolvedClientCapabilities{}
541+
caps.Workspace.DidChangeWatchedFiles.RelativePatternSupport = true
542+
ctx := lsproto.WithClientCapabilities(context.Background(), caps)
543+
init, utils := projecttestutil.GetSessionInitOptions(files, &project.SessionOptions{
544+
CurrentDirectory: "/home/project",
545+
DefaultLibraryPath: bundled.LibPath(),
546+
TypingsLocation: projecttestutil.TestTypingsLocation,
547+
PositionEncoding: lsproto.PositionEncodingKindUTF8,
548+
RunExternalCode: true,
549+
WatchEnabled: true,
550+
}, nil)
551+
init.BackgroundCtx = ctx
552+
init.Spawner = contentmappertest.NewSpawner()
553+
session := project.NewSession(init)
554+
defer session.Close()
555+
556+
mainURI := lsproto.DocumentUri("file:///home/project/main.ts")
557+
session.DidOpenFile(ctx, mainURI, 1, files["/home/project/main.ts"].(string), lsproto.LanguageKindTypeScript)
558+
_, err := session.GetLanguageService(ctx, mainURI)
559+
assert.NilError(t, err)
560+
configuredProject := session.Snapshot().GetDefaultProject(mainURI)
561+
assert.Assert(t, configuredProject != nil)
562+
mappers := configuredProject.CommandLine.ContentMappers()
563+
assert.Equal(t, len(mappers), 1)
564+
assert.Equal(t, mappers[0].PackageDirectory, tspath.RootedDirectoryPath("/home/mapper"))
565+
session.WaitForBackgroundTasks()
566+
assert.Assert(t, utils.WatchesFile(packageJsonPath), "expected the mapper package manifest to be watched")
567+
568+
fixedManifest := contentmappertest.PackageJSON(contentmappertest.TransformingMapper)
569+
assert.NilError(t, utils.FS().WriteFile(packageJsonPath, fixedManifest))
570+
session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{
571+
Uri: lsproto.DocumentUri("file://" + packageJsonPath),
572+
Type: lsproto.FileChangeTypeChanged,
573+
}})
574+
575+
languageService, err := session.GetLanguageService(ctx, mainURI)
576+
assert.NilError(t, err)
577+
boxFile := languageService.GetProgram().GetSourceFile("/home/project/app.box")
578+
assert.Assert(t, boxFile != nil, "expected app.box in the rebuilt program")
579+
assert.Assert(t, strings.Contains(boxFile.Text(), "export const version = 7;"), "expected fixed mapper manifest to be reloaded: %q", boxFile.Text())
580+
}
581+
494582
func TestContentMapperSupplementalFileClonedOnEdit(t *testing.T) {
495583
t.Parallel()
496584
files := map[string]any{

‎tsc/internal/tsoptions/contentmappers.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import (
1515
"github.com/microsoft/TypeScript/tsc/internal/vfs"
1616
)
1717

18-
// resolveContentMapperManifest locates packageName in node_modules (walking up from the directory of
19-
// containingFile via node module resolution) and reads its package.json to produce the mapper's manifest
18+
// resolveContentMapperManifest locates packageName as a directory path relative to containingFile or in
19+
// node_modules (walking up via node module resolution) and reads its package.json to produce the mapper's manifest
2020
// and package directory. It never executes the package. On failure it returns a diagnostic describing why
2121
// the mapper could not be resolved; on success the diagnostic is nil.
2222
type contentMapperResolutionHost struct {

‎tsc/internal/tsoptions/contentmappers_test.go‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,3 +270,77 @@ func TestResolveContentMapperManifest(t *testing.T) {
270270
_, _, diagnostic = resolveContentMapperManifest(NewDefaultParseConfigHost(fs), "/home/project/tsconfig.json", "bad-output-extension")
271271
assert.Equal(t, diagnostic.Code(), diagnostics.The_typescript_contentMapper_outputExtensions_of_the_content_mapper_package_0_contains_an_invalid_mapping_from_1_to_2_Extensions_must_be_non_empty_and_begin_with_a.Code())
272272
}
273+
274+
func TestResolveContentMapperManifestPaths(t *testing.T) {
275+
t.Parallel()
276+
277+
for _, test := range []struct {
278+
packageName string
279+
packageDirectory tspath.RootedDirectoryPath
280+
}{
281+
{".", "/home/project"},
282+
{"..", "/home"},
283+
{"./mapper", "/home/project/mapper"},
284+
{"../mapper", "/home/mapper"},
285+
{`.\mapper`, "/home/project/mapper"},
286+
{`..\mapper`, "/home/mapper"},
287+
{"/home/mapper", "/home/mapper"},
288+
{`C:\mapper`, "C:/mapper"},
289+
} {
290+
t.Run(test.packageName, func(t *testing.T) {
291+
t.Parallel()
292+
files := map[string]string{
293+
"/home/node_modules/unrelated/package.json": `{
294+
"name": "unrelated",
295+
"typescript": { "contentMapper": { "exec": ["run"] } }
296+
}`,
297+
"/home/package.json": `{"name": "unrelated"}`,
298+
}
299+
files[test.packageDirectory.ResolveFile("package.json").AsString()] = `{
300+
"name": "mapper",
301+
"typescript": { "contentMapper": { "exec": ["run"] } }
302+
}`
303+
containingFile := tspath.RootedFilePath("/home/project/tsconfig.json")
304+
if test.packageName == `C:\mapper` {
305+
files = map[string]string{
306+
"C:/mapper/package.json": files["C:/mapper/package.json"],
307+
}
308+
containingFile = "C:/project/tsconfig.json"
309+
}
310+
fs := vfstest.FromMap(files, tspath.CaseSensitive)
311+
for _, allowed := range []bool{false, true} {
312+
host := &contentMapperTestParseConfigHost{ParseConfigHost: NewDefaultParseConfigHost(fs), allowed: allowed}
313+
manifest, packageDirectory, diagnostic := resolveContentMapperManifest(host, containingFile, test.packageName)
314+
assert.Equal(t, host.packageDirectory, test.packageDirectory)
315+
if allowed {
316+
assert.Assert(t, diagnostic == nil)
317+
assert.Equal(t, packageDirectory, test.packageDirectory)
318+
assert.Equal(t, manifest.Name, "mapper")
319+
assert.DeepEqual(t, manifest.Exec, []string{"run"})
320+
} else {
321+
assert.Assert(t, diagnostic != nil)
322+
assert.Equal(t, diagnostic.Code(), diagnostics.The_content_mapper_package_0_is_outside_the_open_workspace_and_will_not_be_loaded.Code())
323+
}
324+
}
325+
})
326+
}
327+
}
328+
329+
func TestResolveContentMapperManifestMissingPaths(t *testing.T) {
330+
t.Parallel()
331+
332+
for _, packageName := range []string{"./does-not-exist", "../does-not-exist", "/does-not-exist", `C:\does-not-exist`, "../no-manifest", "../file"} {
333+
t.Run(packageName, func(t *testing.T) {
334+
t.Parallel()
335+
fs := vfstest.FromMap(map[string]string{
336+
"/home/package.json": `{"name": "unrelated"}`,
337+
"/home/node_modules/unrelated/package.json": `{"name": "unrelated"}`,
338+
"/home/no-manifest/index.js": "",
339+
"/home/file": "",
340+
}, tspath.CaseSensitive)
341+
_, _, diagnostic := resolveContentMapperManifest(NewDefaultParseConfigHost(fs), "/home/project/tsconfig.json", packageName)
342+
assert.Assert(t, diagnostic != nil)
343+
assert.Equal(t, diagnostic.Code(), diagnostics.The_content_mapper_package_0_could_not_be_resolved.Code())
344+
})
345+
}
346+
}

‎tsc/internal/tsoptions/tsconfigparsing.go‎

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,14 @@ import (
2525
)
2626

2727
type extendsResult struct {
28-
options *parsedCompilerOptions
29-
include []any
30-
exclude []any
31-
files []any
32-
contentMappers []any
33-
compileOnSave bool
34-
extendedSourceFiles collections.Set[tspath.RootedFilePath]
28+
options *parsedCompilerOptions
29+
include []any
30+
exclude []any
31+
files []any
32+
contentMappers []any
33+
contentMappersConfigFile tspath.RootedFilePath
34+
compileOnSave bool
35+
extendedSourceFiles collections.Set[tspath.RootedFilePath]
3536
}
3637

3738
type configFileSpecs struct {
@@ -85,10 +86,11 @@ func (e *ExtendedConfigCacheEntry) ExtendedFileNames() []tspath.RootedFilePath {
8586
}
8687

8788
type parsedTsconfig struct {
88-
raw any
89-
options *parsedCompilerOptions
90-
typeAcquisition *core.TypeAcquisition
91-
extendedConfigPaths []tspath.RootedFilePath
89+
raw any
90+
options *parsedCompilerOptions
91+
typeAcquisition *core.TypeAcquisition
92+
extendedConfigPaths []tspath.RootedFilePath
93+
contentMappersConfigFile tspath.RootedFilePath
9294
}
9395

9496
type parsedCompilerOptions struct {
@@ -199,10 +201,11 @@ func parseOwnConfigOfJsonSourceFile(
199201
))
200202
}
201203
return &parsedTsconfig{
202-
raw: json,
203-
options: compilerOptions,
204-
typeAcquisition: typeAcquisition,
205-
extendedConfigPaths: extendedConfigPaths,
204+
raw: json,
205+
options: compilerOptions,
206+
typeAcquisition: typeAcquisition,
207+
extendedConfigPaths: extendedConfigPaths,
208+
contentMappersConfigFile: configFileName,
206209
}, errors
207210
}
208211

@@ -901,10 +904,11 @@ func parseOwnConfigOfJson(
901904
errors = append(errors, err...)
902905
}
903906
parsedConfig := &parsedTsconfig{
904-
raw: json,
905-
options: options,
906-
typeAcquisition: typeAcquisition,
907-
extendedConfigPaths: extendedConfigPaths,
907+
raw: json,
908+
options: options,
909+
typeAcquisition: typeAcquisition,
910+
extendedConfigPaths: extendedConfigPaths,
911+
contentMappersConfigFile: configFileName,
908912
}
909913
return parsedConfig, errors
910914
}
@@ -1087,6 +1091,7 @@ func parseConfig(
10871091
setPropertyValue("files")
10881092
if extendedRawMap, ok := extendsRaw.(*collections.OrderedMap[string, any]); ok && extendedRawMap.Has("contentMappers") {
10891093
result.contentMappers, _ = extendedRawMap.GetOrZero("contentMappers").([]any)
1094+
result.contentMappersConfigFile = extendedConfig.contentMappersConfigFile
10901095
}
10911096
if extendedRawMap, ok := extendsRaw.(*collections.OrderedMap[string, any]); ok && extendedRawMap.Has("compileOnSave") {
10921097
if compileOnSave, ok := extendedRawMap.GetOrZero("compileOnSave").(bool); ok {
@@ -1120,6 +1125,7 @@ func parseConfig(
11201125
}
11211126
if result.contentMappers != nil && !ownConfig.raw.(*collections.OrderedMap[string, any]).Has("contentMappers") {
11221127
ownConfig.raw.(*collections.OrderedMap[string, any]).Set("contentMappers", result.contentMappers)
1128+
ownConfig.contentMappersConfigFile = result.contentMappersConfigFile
11231129
}
11241130
if result.compileOnSave && !ownConfig.raw.(*collections.OrderedMap[string, any]).Has("compileOnSave") {
11251131
ownConfig.raw.(*collections.OrderedMap[string, any]).Set("compileOnSave", result.compileOnSave)
@@ -1398,9 +1404,12 @@ func parseJsonConfigFileContentWorker(
13981404
} else if len(contentMappers) > 0 {
13991405
// Resolve each mapper's package.json now so its name, version, and run command are available to
14001406
// everything downstream (diagnostics, build-info staleness) without executing anything.
1401-
containingFile := configFileName
1407+
containingFile := parsedConfig.contentMappersConfigFile
14021408
if containingFile == "" {
1403-
containingFile = basePathForFileNames.ResolveFile("tsconfig.json")
1409+
containingFile = configFileName
1410+
if containingFile == "" {
1411+
containingFile = basePathForFileNames.ResolveFile("tsconfig.json")
1412+
}
14041413
}
14051414
resolvedContentMappers := make([]*contentmapper.Mapper, 0, len(contentMappers))
14061415
for j, mapper := range contentMappers {

0 commit comments

Comments
 (0)