From 739d4c30cc28f310ffd9aaa7d8d5123a503b0f23 Mon Sep 17 00:00:00 2001 From: Sverre Johansen Date: Fri, 19 Jun 2026 11:46:50 +0200 Subject: [PATCH] Resolve symlinked project-reference directory (index.ts) subpaths to source The source-of-project-reference redirect failed for a directory subpath import (e.g. b/lib/File, source b/src/File/index.ts) when the referenced package is reached through a symlinked node_modules entry and its lib/ is unbuilt. knownSymlinks is populated lazily by handleDirectoryCouldBeSymlink only when an existing directory is probed. For a directory subpath the resolver DirectoryExists-probes the unbuilt subpath before the package root, so the symlink isn't registered yet and the (cached) lookup misses. Discover and register the package symlink on demand on a node_modules miss, by walking up to the nearest existing ancestor and reusing handleDirectoryCouldBeSymlink; the existing symlink-aware fallback then resolves to source. Runs only on a miss, so built/normal resolution is unaffected. Fixes #4373 --- .../compiler/projectreferencedtsfakinghost.go | 32 +++++++++++++++ .../project/projectreferencesprogram_test.go | 39 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/internal/compiler/projectreferencedtsfakinghost.go b/internal/compiler/projectreferencedtsfakinghost.go index 8cbcda0ac5f..41c017d098a 100644 --- a/internal/compiler/projectreferencedtsfakinghost.go +++ b/internal/compiler/projectreferencedtsfakinghost.go @@ -162,6 +162,31 @@ func (fs *projectReferenceDtsFakingVfs) handleDirectoryCouldBeSymlink(directory }) } +// registerNodeModulesSymlinkFromAncestor walks up from a node_modules path to +// the nearest ancestor directory that actually exists on disk and, if that +// directory is a symlink (e.g. a symlinked package root in a workspace install), +// records it in knownSymlinks. This handles the case where the resolver probes a +// path inside an unbuilt package before directory-probing the package root, so +// handleDirectoryCouldBeSymlink has not fired for it yet. +func (fs *projectReferenceDtsFakingVfs) registerNodeModulesSymlinkFromAncestor(fileOrDirectory string) { + if !strings.Contains(fileOrDirectory, "/node_modules/") { + return + } + realFS := fs.projectReferenceFileMapper.opts.Host.FS() + dir := tspath.GetDirectoryPath(fileOrDirectory) + for strings.Contains(dir+"/", "/node_modules/") { + if realFS.DirectoryExists(dir) { + fs.handleDirectoryCouldBeSymlink(dir) + return + } + parent := tspath.GetDirectoryPath(dir) + if parent == dir { + return + } + dir = parent + } +} + func (fs *projectReferenceDtsFakingVfs) fileOrDirectoryExistsUsingSource(fileOrDirectory string, isFile bool) bool { fileOrDirectoryExistsUsingSource := core.IfElse(isFile, fs.fileExistsIfProjectReferenceDts, fs.directoryExistsIfProjectReferenceDeclDir) // Check current directory or file @@ -170,6 +195,13 @@ func (fs *projectReferenceDtsFakingVfs) fileOrDirectoryExistsUsingSource(fileOrD return result == core.TSTrue } + // The resolver can probe a path inside an unbuilt referenced package (whose + // declaration outputs do not exist on disk) before it ever directory-probes + // that package's symlinked root, so the symlink may not be in knownSymlinks + // yet. Discover and register it on demand from the nearest existing ancestor + // directory, then fall through to the existing symlink-aware lookup. + fs.registerNodeModulesSymlinkFromAncestor(fileOrDirectory) + knownDirectoryLinks := fs.knownSymlinks.Directories() if knownDirectoryLinks.Size() == 0 { return false diff --git a/internal/project/projectreferencesprogram_test.go b/internal/project/projectreferencesprogram_test.go index 97438c71989..63e05388a76 100644 --- a/internal/project/projectreferencesprogram_test.go +++ b/internal/project/projectreferencesprogram_test.go @@ -250,6 +250,24 @@ func TestProjectReferencesProgram(t *testing.T) { assert.Assert(t, barFile != nil) }) + t.Run("references through symlink to a directory subpath (index file)", func(t *testing.T) { + t.Parallel() + files, aTest, bIndex := filesForSymlinkReferencesToDirectoryIndexSubpath("") + session, _ := projecttestutil.Setup(files) + + uri := lsconv.FileNameToDocumentURI(aTest) + session.DidOpenFile(context.Background(), uri, 1, files[aTest].(string), lsproto.LanguageKindTypeScript) + + snapshot := session.Snapshot() + projects := snapshot.ProjectCollection.Projects() + assert.Equal(t, len(projects), 1) + p := projects[0] + assert.Equal(t, p.Kind, project.KindConfigured) + + indexFile := p.Program.GetSourceFile(bIndex) + assert.Assert(t, indexFile != nil) + }) + t.Run("when new file is added to referenced project", func(t *testing.T) { t.Parallel() files := filesForReferencedProjectProgram(false) @@ -360,6 +378,27 @@ func filesForSymlinkReferencesInSubfolder(preserveSymlinks bool, scope string) ( return files, aTest, bFoo, bBar } +// filesForSymlinkReferencesToDirectoryIndexSubpath imports a directory subpath +// (`b/lib/File`, whose source is `B/src/File/index.ts`) of a symlinked, +// unbuilt referenced project. The resolver directory-probes the unbuilt subpath +// before the package root, so the redirect must still map it to source. +func filesForSymlinkReferencesToDirectoryIndexSubpath(scope string) (files map[string]any, aTest string, bIndex string) { + aTest = "/user/username/projects/myproject/packages/A/src/test.ts" + bIndex = "/user/username/projects/myproject/packages/B/src/File/index.ts" + files = map[string]any{ + "/user/username/projects/myproject/packages/B/package.json": `{}`, + aTest: fmt.Sprintf(` + import { helper } from '%sb/lib/File'; + helper(); + `, scope), + bIndex: `export function helper() { }`, + fmt.Sprintf(`/user/username/projects/myproject/node_modules/%sb`, scope): vfstest.Symlink("/user/username/projects/myproject/packages/B"), + } + addConfigForPackage(files, "A", false, []string{"../B"}) + addConfigForPackage(files, "B", false, nil) + return files, aTest, bIndex +} + func addConfigForPackage(files map[string]any, packageName string, preserveSymlinks bool, references []string) { compilerOptions := map[string]any{ "outDir": "lib",