Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions internal/checker/nodebuilderimpl.go
Original file line number Diff line number Diff line change
Expand Up @@ -1211,6 +1211,10 @@ func (b *NodeBuilderImpl) getSpecifierForModuleSymbol(symbol *ast.Symbol, overri
},
false, /*forAutoImports*/
)
if len(allSpecifiers) == 0 {
links.specifierCache[cacheKey] = ""
return ""
}
specifier := allSpecifiers[0]
links.specifierCache[cacheKey] = specifier
return specifier
Expand Down
10 changes: 10 additions & 0 deletions internal/compiler/emitHost.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/microsoft/typescript-go/internal/modulespecifiers"
"github.com/microsoft/typescript-go/internal/outputpaths"
"github.com/microsoft/typescript-go/internal/printer"
"github.com/microsoft/typescript-go/internal/symlinks"
"github.com/microsoft/typescript-go/internal/transformers/declarations"
"github.com/microsoft/typescript-go/internal/tsoptions"
"github.com/microsoft/typescript-go/internal/tspath"
Expand Down Expand Up @@ -126,3 +127,12 @@ func (host *emitHost) GetEmitResolver() printer.EmitResolver {
func (host *emitHost) IsSourceFileFromExternalLibrary(file *ast.SourceFile) bool {
return host.program.IsSourceFileFromExternalLibrary(file)
}

func (host *emitHost) GetSymlinkCache() *symlinks.KnownSymlinks {
return host.program.GetSymlinkCache()
}

func (host *emitHost) ResolveModuleName(moduleName string, containingFile string, resolutionMode core.ResolutionMode) *module.ResolvedModule {
resolved, _ := host.program.resolver.ResolveModuleName(moduleName, containingFile, resolutionMode, nil)
return resolved
}
53 changes: 0 additions & 53 deletions internal/compiler/knownsymlinks.go

This file was deleted.

93 changes: 93 additions & 0 deletions internal/compiler/program.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ import (
"github.com/microsoft/typescript-go/internal/printer"
"github.com/microsoft/typescript-go/internal/scanner"
"github.com/microsoft/typescript-go/internal/sourcemap"
"github.com/microsoft/typescript-go/internal/symlinks"
"github.com/microsoft/typescript-go/internal/tsoptions"
"github.com/microsoft/typescript-go/internal/tspath"
"github.com/microsoft/typescript-go/internal/vfs"
)

type ProgramOptions struct {
Expand Down Expand Up @@ -66,6 +68,7 @@ type Program struct {
// Cached unresolved imports for ATA
unresolvedImportsOnce sync.Once
unresolvedImports *collections.Set[string]
knownSymlinks *symlinks.KnownSymlinks
}

// FileExists implements checker.Program.
Expand Down Expand Up @@ -210,6 +213,11 @@ func NewProgram(opts ProgramOptions) *Program {
p.initCheckerPool()
p.processedFiles = processAllProgramFiles(p.opts, p.SingleThreaded())
p.verifyCompilerOptions()
p.knownSymlinks = symlinks.NewKnownSymlink(p.GetCurrentDirectory(), p.UseCaseSensitiveFileNames())
if len(p.resolvedModules) > 0 || len(p.typeResolutionsInFile) > 0 {
p.knownSymlinks.SetSymlinksFromResolutions(p.ForEachResolvedModule, p.ForEachResolvedTypeReferenceDirective)
}
p.populateSymlinkCacheFromResolutions()
return p
}

Expand Down Expand Up @@ -240,6 +248,11 @@ func (p *Program) UpdateProgram(changedFilePath tspath.Path, newHost CompilerHos
result.filesByPath = maps.Clone(result.filesByPath)
result.filesByPath[newFile.Path()] = newFile
updateFileIncludeProcessor(result)
result.knownSymlinks = symlinks.NewKnownSymlink(result.GetCurrentDirectory(), result.UseCaseSensitiveFileNames())
if len(result.resolvedModules) > 0 || len(result.typeResolutionsInFile) > 0 {
result.knownSymlinks.SetSymlinksFromResolutions(result.ForEachResolvedModule, result.ForEachResolvedTypeReferenceDirective)
}
p.populateSymlinkCacheFromResolutions()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should just copy the symlink cache on program clone. I think it's intended to be immutable after initialization, so by copy, I mean the new program can share the same reference. (If the cache were mutable, we would have to add a clone method on it.) This function needs to be dirt cheap, and calling it means you're asserting that the only relevant change that's happened in the Program's whole world is to the file you're passing in.

return result, true
}

Expand Down Expand Up @@ -1635,6 +1648,86 @@ func (p *Program) SourceFileMayBeEmitted(sourceFile *ast.SourceFile, forceDtsEmi
return sourceFileMayBeEmitted(sourceFile, p, forceDtsEmit)
}

func (p *Program) GetSymlinkCache() *symlinks.KnownSymlinks {
// if p.Host().GetSymlinkCache() != nil {
// return p.Host().GetSymlinkCache()
// }
if p.knownSymlinks == nil {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This condition looks to be impossible as-written, but I think lazy initialization is a good idea. However, you need to guard the field initialization with a sync.Once like the other lazy computed caches.

p.knownSymlinks = symlinks.NewKnownSymlink(p.GetCurrentDirectory(), p.UseCaseSensitiveFileNames())
// In declaration-only builds, the symlink cache might not be populated yet
// because module resolution was skipped. Populate it now if we have resolutions.
Comment on lines +1657 to +1658
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t recall exactly how this happened in Strada, but I don’t think this comment applies in Corsa.

Copy link
Author

@chase chase Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This occurs regularly in pnpm workspaces when running tsgo --build --emitDeclarationsOnly, if I recall correctly.

if len(p.resolvedModules) > 0 || len(p.typeResolutionsInFile) > 0 {
p.knownSymlinks.SetSymlinksFromResolutions(p.ForEachResolvedModule, p.ForEachResolvedTypeReferenceDirective)
}
p.populateSymlinkCacheFromResolutions()
}
return p.knownSymlinks
}

func (p *Program) populateSymlinkCacheFromResolutions() {
p.Host().FS().WalkDir(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t think we should do a WalkDir here. Basically, I think we should find the package.json for each non-declaration input file, and then resolve the dependencies for each unique package.json. Finding the package.json could be moved to file loading to avoid a separate loop over all the files (and in fact, this already happens in order to determine the module kind under certain settings/situations; with this we could make it unconditional).

p.GetCurrentDirectory(),
func(path string, d vfs.DirEntry, e error) error {
if e != nil {
return e
}
if !d.IsDir() {
return nil
}

packageJsonDir := p.GetNearestAncestorDirectoryWithPackageJson(tspath.GetDirectoryPath(path))
if packageJsonDir == "" {
return nil
}

packageJsonPath := tspath.CombinePaths(packageJsonDir, "package.json")
// Check if we've already populated symlinks for this package.json
if p.knownSymlinks.IsPackagePopulated(packageJsonPath) {
return nil
}
pkgJsonInfo := p.GetPackageJsonInfo(packageJsonPath)
if pkgJsonInfo == nil {
return nil
}
pkgJson := pkgJsonInfo.GetContents()
if pkgJson == nil {
return nil
}
p.knownSymlinks.PopulateFromResolutions(packageJsonPath, pkgJson, p.ResolveModuleName)
Copy link
Member

@andrewbranch andrewbranch Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a two ways to save some work here:

  • For a lot of these dependencies, we probably already did a resolution of that package as part of normal program construction, which means we already found the same symlink by iterating over the program's resolved modules. If fileLoader were to keep a set of package names it resolved, we could skip anything that's already been done.
  • For resolutions we still need to make, we could save a few file system hits by exposing a resolver function that just gets the dependency’s package.json instead of its main entrypoint.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also think the naming of this (both the function we're in and the function being called here) should say something about dependencies

return nil
},
)
}

func (p *Program) ResolveModuleName(moduleName string, containingFile string, resolutionMode core.ResolutionMode) *module.ResolvedModule {
resolved, _ := p.resolver.ResolveModuleName(moduleName, containingFile, resolutionMode, nil)
return resolved
}

func (p *Program) ForEachResolvedModule(callback func(resolution *module.ResolvedModule, moduleName string, mode core.ResolutionMode, filePath tspath.Path), file *ast.SourceFile) {
forEachResolution(p.resolvedModules, callback, file)
}

func (p *Program) ForEachResolvedTypeReferenceDirective(callback func(resolution *module.ResolvedTypeReferenceDirective, moduleName string, mode core.ResolutionMode, filePath tspath.Path), file *ast.SourceFile) {
forEachResolution(p.typeResolutionsInFile, callback, file)
}

func forEachResolution[T any](resolutionCache map[tspath.Path]module.ModeAwareCache[T], callback func(resolution T, moduleName string, mode core.ResolutionMode, filePath tspath.Path), file *ast.SourceFile) {
if file != nil {
if resolutions, ok := resolutionCache[file.Path()]; ok {
for key, resolution := range resolutions {
callback(resolution, key.Name, key.Mode, file.Path())
}
}
} else {
for filePath, resolutions := range resolutionCache {
for key, resolution := range resolutions {
callback(resolution, key.Name, key.Mode, filePath)
}
}
}
}

var plainJSErrors = collections.NewSetFromItems(
// binder errors
diagnostics.Cannot_redeclare_block_scoped_variable_0.Code(),
Expand Down
44 changes: 44 additions & 0 deletions internal/compiler/program_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,3 +312,47 @@ func BenchmarkNewProgram(b *testing.B) {
}
})
}

// TestGetSymlinkCacheLazyPopulation verifies that GetSymlinkCache() populates the cache
// from resolved modules. This prevents TS2742 errors with .pnpm paths in pnpm workspaces
// when doing declaration-only builds.
func TestGetSymlinkCacheLazyPopulation(t *testing.T) {
t.Parallel()

if !bundled.Embedded {
t.Skip("bundled files are not embedded")
}

fs := vfstest.FromMap[any](nil, false /*useCaseSensitiveFileNames*/)
fs = bundled.WrapFS(fs)

_ = fs.WriteFile("/project/src/index.ts", "import { foo } from 'my-package';", false)
_ = fs.WriteFile("/project/node_modules/my-package/index.d.ts", "export const foo: string;", false)

opts := core.CompilerOptions{
Target: core.ScriptTargetESNext,
ModuleResolution: core.ModuleResolutionKindNodeNext,
}

program := compiler.NewProgram(compiler.ProgramOptions{
Config: &tsoptions.ParsedCommandLine{
ParsedConfig: &core.ParsedOptions{
FileNames: []string{"/project/src/index.ts"},
CompilerOptions: &opts,
},
},
Host: compiler.NewCompilerHost("/project", fs, bundled.LibPath(), nil, nil),
})

cache := program.GetSymlinkCache()
assert.Assert(t, cache != nil)
assert.Assert(t, cache.HasProcessedResolutions)

hasResolutions := false
cache.Files().Range(func(key tspath.Path, value string) bool {
hasResolutions = true
return false
})

assert.Assert(t, hasResolutions || cache.HasProcessedResolutions)
}
9 changes: 5 additions & 4 deletions internal/compiler/projectreferencedtsfakinghost.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/microsoft/typescript-go/internal/collections"
"github.com/microsoft/typescript-go/internal/core"
"github.com/microsoft/typescript-go/internal/module"
"github.com/microsoft/typescript-go/internal/symlinks"
"github.com/microsoft/typescript-go/internal/tspath"
"github.com/microsoft/typescript-go/internal/vfs"
"github.com/microsoft/typescript-go/internal/vfs/cachedvfs"
Expand All @@ -26,7 +27,7 @@ func newProjectReferenceDtsFakingHost(loader *fileLoader) module.ResolutionHost
fs: cachedvfs.From(&projectReferenceDtsFakingVfs{
projectReferenceFileMapper: loader.projectReferenceFileMapper,
dtsDirectories: loader.dtsDirectories,
knownSymlinks: knownSymlinks{},
knownSymlinks: symlinks.KnownSymlinks{},
}),
}
return host
Expand All @@ -45,7 +46,7 @@ func (h *projectReferenceDtsFakingHost) GetCurrentDirectory() string {
type projectReferenceDtsFakingVfs struct {
projectReferenceFileMapper *projectReferenceFileMapper
dtsDirectories collections.Set[tspath.Path]
knownSymlinks knownSymlinks
knownSymlinks symlinks.KnownSymlinks
}

var _ vfs.FS = (*projectReferenceDtsFakingVfs)(nil)
Expand Down Expand Up @@ -150,7 +151,7 @@ func (fs *projectReferenceDtsFakingVfs) handleDirectoryCouldBeSymlink(directory
// not symlinked
return
}
fs.knownSymlinks.SetDirectory(directory, directoryPath, &knownDirectoryLink{
fs.knownSymlinks.SetDirectory(directory, directoryPath, &symlinks.KnownDirectoryLink{
Real: tspath.EnsureTrailingDirectorySeparator(realDirectory),
RealPath: realPath,
})
Expand Down Expand Up @@ -181,7 +182,7 @@ func (fs *projectReferenceDtsFakingVfs) fileOrDirectoryExistsUsingSource(fileOrD

// If it contains node_modules check if its one of the symlinked path we know of
var exists bool
knownDirectoryLinks.Range(func(directoryPath tspath.Path, knownDirectoryLink *knownDirectoryLink) bool {
knownDirectoryLinks.Range(func(directoryPath tspath.Path, knownDirectoryLink *symlinks.KnownDirectoryLink) bool {
relative, hasPrefix := strings.CutPrefix(string(fileOrDirectoryPath), string(directoryPath))
if !hasPrefix {
return true
Expand Down
Loading