Skip to content
Draft
Show file tree
Hide file tree
Changes from 11 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
35 changes: 33 additions & 2 deletions internal/checker/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,7 @@ type Program interface {
GetProjectReferenceFromOutputDts(path tspath.Path) *tsoptions.SourceOutputAndProjectReference
GetRedirectForResolution(file ast.HasFileName) *tsoptions.ParsedCommandLine
CommonSourceDirectory() string
GetDeduplicatedPackagePath(path tspath.Path) tspath.Path
}

type Host interface {
Expand Down Expand Up @@ -26975,8 +26976,14 @@ func (c *Checker) compareProperties(sourceProp *ast.Symbol, targetProp *ast.Symb
return TernaryFalse
}
if sourcePropAccessibility != ast.ModifierFlagsNone {
if c.getTargetSymbol(sourceProp) != c.getTargetSymbol(targetProp) {
return TernaryFalse
sourceTarget := c.getTargetSymbol(sourceProp)
targetTarget := c.getTargetSymbol(targetProp)
if sourceTarget != targetTarget {
// Check if these symbols are from duplicate package instances (same package installed
// in different locations). If they map to the same canonical file, treat them as identical.
if !c.areSymbolsFromSamePackageFile(sourceTarget, targetTarget) {
return TernaryFalse
}
}
} else {
if (sourceProp.Flags & ast.SymbolFlagsOptional) != (targetProp.Flags & ast.SymbolFlagsOptional) {
Expand All @@ -26989,6 +26996,30 @@ func (c *Checker) compareProperties(sourceProp *ast.Symbol, targetProp *ast.Symb
return compareTypes(c.getTypeOfSymbol(sourceProp), c.getTypeOfSymbol(targetProp))
}

// areSymbolsFromSamePackageFile checks if two symbols come from files that are duplicates
// of the same package file (same package name@version installed in different locations).
func (c *Checker) areSymbolsFromSamePackageFile(source *ast.Symbol, target *ast.Symbol) bool {
// If package deduplication is disabled, don't treat any files as duplicates
if c.compilerOptions.DisablePackageDeduplication.IsTrue() {
return false
}
sourceDecl := source.ValueDeclaration
targetDecl := target.ValueDeclaration
if sourceDecl == nil || targetDecl == nil {
return false
}
sourceFile := ast.GetSourceFileOfNode(sourceDecl)
targetFile := ast.GetSourceFileOfNode(targetDecl)
if sourceFile == nil || targetFile == nil {
return false
}
// If they're from the same file, they can't have been deduplicated.
if sourceFile == targetFile {
return false
}
return c.program.GetDeduplicatedPackagePath(sourceFile.Path()) == c.program.GetDeduplicatedPackagePath(targetFile.Path())
}

func compareTypesEqual(s *Type, t *Type) Ternary {
if s == t {
return TernaryTrue
Expand Down
2 changes: 1 addition & 1 deletion internal/checker/relater.go
Original file line number Diff line number Diff line change
Expand Up @@ -4203,7 +4203,7 @@ func (r *Relater) propertyRelatedTo(source *Type, target *Type, sourceProp *ast.
targetPropFlags := getDeclarationModifierFlagsFromSymbol(targetProp)
switch {
case sourcePropFlags&ast.ModifierFlagsPrivate != 0 || targetPropFlags&ast.ModifierFlagsPrivate != 0:
if sourceProp.ValueDeclaration != targetProp.ValueDeclaration {
if sourceProp.ValueDeclaration != targetProp.ValueDeclaration && !r.c.areSymbolsFromSamePackageFile(sourceProp, targetProp) {
if reportErrors {
if sourcePropFlags&ast.ModifierFlagsPrivate != 0 && targetPropFlags&ast.ModifierFlagsPrivate != 0 {
r.reportError(diagnostics.Types_have_separate_declarations_of_a_private_property_0, r.c.symbolToString(targetProp))
Expand Down
9 changes: 8 additions & 1 deletion internal/compiler/fileloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,14 @@ type processedFiles struct {
// if file was included using source file and its output is actually part of program
// this contains mapping from output to source file
outputFileToProjectReferenceSource map[tspath.Path]string
finishedProcessing bool
// Maps a source file path to the name of the package it was imported with
sourceFileToPackageName map[tspath.Path]string
// Key is a file path. Value is the list of files that redirect to it (same package, different install location)
redirectTargetsMap map[tspath.Path][]string
// Maps any path (canonical or redirect target) to its canonical path.
// Canonical paths map to themselves; redirect targets map to their canonical path.
deduplicatedPathMap map[tspath.Path]tspath.Path
finishedProcessing bool
}

type jsxRuntimeImportSpecifier struct {
Expand Down
93 changes: 93 additions & 0 deletions internal/compiler/filesparser.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package compiler

import (
"cmp"
"maps"
"math"
"slices"
"sync"
Expand Down Expand Up @@ -408,6 +410,16 @@ func (w *filesParser) getProcessedFiles(loader *fileLoader) processedFiles {
}
}

// Build sourceFileToPackageName and redirectTargetsMap by scanning all resolved modules.
// This is done after loading is complete to ensure determinism regardless of load order.
// Skip this if package deduplication is disabled.
var sourceFileToPackageName map[tspath.Path]string
var redirectTargetsMap map[tspath.Path][]string
var deduplicatedPathMap map[tspath.Path]tspath.Path
if !loader.opts.Config.CompilerOptions().DisablePackageDeduplication.IsTrue() {
sourceFileToPackageName, redirectTargetsMap, deduplicatedPathMap = computePackageRedirects(resolvedModules, loader.toPath)
}

return processedFiles{
finishedProcessing: true,
resolver: loader.resolver,
Expand All @@ -424,7 +436,88 @@ func (w *filesParser) getProcessedFiles(loader *fileLoader) processedFiles {
missingFiles: missingFiles,
includeProcessor: includeProcessor,
outputFileToProjectReferenceSource: outputFileToProjectReferenceSource,
sourceFileToPackageName: sourceFileToPackageName,
redirectTargetsMap: redirectTargetsMap,
deduplicatedPathMap: deduplicatedPathMap,
}
}

// computePackageRedirects builds the sourceFileToPackageName and redirectTargetsMap by scanning
// all resolved modules. Files from the same package (same name@version) are deduplicated:
// the lexicographically first path becomes the "canonical" one and others redirect to it.
// This is done after loading completes to ensure determinism regardless of concurrent load order.
func computePackageRedirects(
resolvedModules map[tspath.Path]module.ModeAwareCache[*module.ResolvedModule],
toPath func(string) tspath.Path,
) (sourceFileToPackageName map[tspath.Path]string, redirectTargetsMap map[tspath.Path][]string, deduplicatedPathMap map[tspath.Path]tspath.Path) {
// Collect all resolved files with package IDs
// packageIdKey -> list of (resolvedPath, packageName)
type fileInfo struct {
path tspath.Path
fileName string
packageName string
}
packageIdToFiles := make(map[module.PackageId][]fileInfo)

containingFilePaths := slices.AppendSeq(make([]tspath.Path, 0, len(resolvedModules)), maps.Keys(resolvedModules))
slices.Sort(containingFilePaths)

for _, containingPath := range containingFilePaths {
resolutions := resolvedModules[containingPath]
for _, resolution := range resolutions {
if !resolution.IsResolved() {
continue
}
pkgId := resolution.PackageId
if pkgId.Name == "" {
continue
}
resolvedFileName := resolution.ResolvedFileName
resolvedPath := toPath(resolvedFileName)
packageName := pkgId.PackageName()

// Check if we've already recorded this path for this package
files := packageIdToFiles[pkgId]
if !slices.ContainsFunc(files, func(f fileInfo) bool { return f.path == resolvedPath }) {
packageIdToFiles[pkgId] = append(files, fileInfo{path: resolvedPath, fileName: resolvedFileName, packageName: packageName})
}
}
}

// Now for each packageIdKey with multiple files, pick the canonical one (lexicographically first)
// and build the redirect map
sourceFileToPackageName = make(map[tspath.Path]string)
redirectTargetsMap = make(map[tspath.Path][]string)
deduplicatedPathMap = make(map[tspath.Path]tspath.Path)

for _, files := range packageIdToFiles {
if len(files) == 0 {
continue
}

slices.SortFunc(files, func(a, b fileInfo) int { return cmp.Compare(a.path, b.path) })

canonicalPath := files[0].path
packageName := files[0].packageName

// Record package name for all files from this package
for _, f := range files {
sourceFileToPackageName[f.path] = packageName
}

// If there are multiple files, the others redirect to the canonical one
if len(files) > 1 {
// Canonical path maps to itself
deduplicatedPathMap[canonicalPath] = canonicalPath
for _, f := range files[1:] {
redirectTargetsMap[canonicalPath] = append(redirectTargetsMap[canonicalPath], f.fileName)
// Redirect target maps to canonical
deduplicatedPathMap[f.path] = canonicalPath
}
}
}

return sourceFileToPackageName, redirectTargetsMap, deduplicatedPathMap
}

func (w *filesParser) addIncludeReason(includeProcessor *includeProcessor, task *parseTask, reason *FileIncludeReason) {
Expand Down
31 changes: 29 additions & 2 deletions internal/compiler/program.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,21 @@ func (p *Program) GetPackageJsonInfo(pkgJsonPath string) *packagejson.InfoCacheE
return nil
}

// GetRedirectTargets implements checker.Program.
// GetRedirectTargets returns the list of file paths that redirect to the given path.
// These are files from the same package (same name@version) installed in different locations.
func (p *Program) GetRedirectTargets(path tspath.Path) []string {
return nil // !!! TODO: project references support
return p.redirectTargetsMap[path]
}

// GetDeduplicatedPackagePath returns the canonical path for a file that may be a duplicate package.
// If the file is a redirect target (i.e., it redirects to a canonical file), returns the canonical path.
// Otherwise, returns the path unchanged.
func (p *Program) GetDeduplicatedPackagePath(path tspath.Path) tspath.Path {
if canonicalPath, ok := p.deduplicatedPathMap[path]; ok {
return canonicalPath
}
// Not part of any redirect group, return as-is
return path
}

// gets the original file that was included in program
Expand Down Expand Up @@ -241,6 +253,21 @@ func (p *Program) UpdateProgram(changedFilePath tspath.Path, newHost CompilerHos
if !canReplaceFileInProgram(oldFile, newFile) {
return NewProgram(newOpts), false
}
// TODO: CHECK THIS
// If this file is part of a package redirect group (same package installed in multiple
// node_modules locations), we need to rebuild the program because the redirect targets
// might need recalculation. A file is in a redirect group if it's either a canonical
// file that others redirect to, or if it redirects to another file.
// if _, isCanonical := p.redirectTargetsMap[changedFilePath]; isCanonical {
// return NewProgram(newOpts), false
// }
// for _, targets := range p.redirectTargetsMap {
// for _, target := range targets {
// if tspath.Path(target) == changedFilePath {
// return NewProgram(newOpts), false
// }
// }
// }
// TODO: reverify compiler options when config has changed?
result := &Program{
opts: newOpts,
Expand Down
1 change: 1 addition & 0 deletions internal/core/compileroptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type CompilerOptions struct {
DisableSourceOfProjectReferenceRedirect Tristate `json:"disableSourceOfProjectReferenceRedirect,omitzero"`
DisableSolutionSearching Tristate `json:"disableSolutionSearching,omitzero"`
DisableReferencedProjectLoad Tristate `json:"disableReferencedProjectLoad,omitzero"`
DisablePackageDeduplication Tristate `json:"disablePackageDeduplication,omitzero"`
ErasableSyntaxOnly Tristate `json:"erasableSyntaxOnly,omitzero"`
ESModuleInterop Tristate `json:"esModuleInterop,omitzero"`
ExactOptionalPropertyTypes Tristate `json:"exactOptionalPropertyTypes,omitzero"`
Expand Down
4 changes: 4 additions & 0 deletions internal/diagnostics/diagnostics_generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions internal/diagnostics/extraDiagnosticMessages.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,9 @@
"Option '{0}' requires value to be greater than '{1}'.": {
"category": "Error",
"code": 5002
},
"Disable deduplication of packages with the same name and version.": {
"category": "Message",
"code": 100011
}
}
1 change: 0 additions & 1 deletion internal/fourslash/_scripts/failingTests.txt
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,6 @@ TestContextuallyTypedFunctionExpressionGeneric1
TestContextualTypingOfGenericCallSignatures2
TestCrossFileQuickInfoExportedTypeDoesNotUseImportType
TestDoubleUnderscoreCompletions
TestDuplicatePackageServices
TestEditJsdocType
TestErrorsAfterResolvingVariableDeclOfMergedVariableAndClassDecl
TestExportDefaultClass
Expand Down
3 changes: 2 additions & 1 deletion internal/fourslash/_scripts/manualTests.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ completionListInClosedFunction05
completionsAtIncompleteObjectLiteralProperty
completionsSelfDeclaring1
completionsWithDeprecatedTag4
duplicatePackageServices_fileChanges
navigationBarFunctionPrototype
navigationBarFunctionPrototype2
navigationBarFunctionPrototype3
Expand All @@ -26,4 +27,4 @@ jsDocFunctionSignatures12
outliningHintSpansForFunction
getOutliningSpans
outliningForNonCompleteInterfaceDeclaration
incrementalParsingWithJsDoc
incrementalParsingWithJsDoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package fourslash_test

import (
"testing"

"github.com/microsoft/typescript-go/internal/fourslash"
"github.com/microsoft/typescript-go/internal/testutil"
)

func TestDuplicatePackageServices_fileChanges(t *testing.T) {
t.Parallel()

defer testutil.RecoverAndFail(t, "Panic on fourslash test")
const content = `// @noImplicitReferences: true
// @Filename: /node_modules/a/index.d.ts
import X from "x";
export function a(x: X): void;
// @Filename: /node_modules/a/node_modules/x/index.d.ts
export default class /*defAX*/X {
private x: number;
}
// @Filename: /node_modules/a/node_modules/x/package.json
{ "name": "x", "version": "1.2./*aVersionPatch*/3" }
// @Filename: /node_modules/b/index.d.ts
import X from "x";
export const b: X;
// @Filename: /node_modules/b/node_modules/x/index.d.ts
export default class /*defBX*/X {
private x: number;
}
// @Filename: /node_modules/b/node_modules/x/package.json
{ "name": "x", "version": "1.2./*bVersionPatch*/3" }
// @Filename: /src/a.ts
import { a } from "a";
import { b } from "b";
a(/*error*/b);`
f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content)
defer done()

f.GoToFile(t, "/src/a.ts")
f.VerifyNumberOfErrorsInCurrentFile(t, 0)

testChangeAndChangeBack := func(versionPatch string, def string) {
// Insert "4" after the version patch marker, changing version from 1.2.3 to 1.2.43
f.GoToMarker(t, versionPatch)
f.Insert(t, "4")

// Insert a space after the definition marker to trigger a recheck
f.GoToMarker(t, def)
f.Insert(t, " ")

// No longer have identical packageId, so we get errors.
f.VerifyErrorExistsAfterMarker(t, "error")

// Undo the changes
f.GoToMarker(t, versionPatch)
f.DeleteAtCaret(t, 1)
f.GoToMarker(t, def)
f.DeleteAtCaret(t, 1)

// Back to being identical.
f.GoToFile(t, "/src/a.ts")
f.VerifyNumberOfErrorsInCurrentFile(t, 0)
}

testChangeAndChangeBack("aVersionPatch", "defAX")
testChangeAndChangeBack("bVersionPatch", "defBX")
}
Loading