Skip to content
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
9b349c1
Fix resolver misport
jakebailey Dec 11, 2025
dc9841e
Update baselines
jakebailey Dec 11, 2025
d81c18e
Implement package deduping kinda
jakebailey Dec 11, 2025
358bd71
Update duplicate package test, sort of intentional behavior
jakebailey Dec 12, 2025
ed7017c
equality
jakebailey Dec 12, 2025
f98b377
rename
jakebailey Dec 12, 2025
ac7cfc5
better comparison
jakebailey Dec 12, 2025
43f6e08
Fix paths
jakebailey Dec 12, 2025
b99c0ff
Cleanup
jakebailey Dec 12, 2025
e25f2bc
Cleanup
jakebailey Dec 12, 2025
044eee2
Manual test
jakebailey Dec 12, 2025
d47b3a7
files are not nil
jakebailey Dec 12, 2025
d7e8232
Go to AST replacing version
jakebailey Dec 15, 2025
af6f313
fmt
jakebailey Dec 15, 2025
03a3d8f
Drop unused
jakebailey Dec 15, 2025
27d78af
Revert "Drop unused"
jakebailey Dec 15, 2025
cf0bc18
Delete files that have been deduped away
jakebailey Dec 15, 2025
fb1e84f
Do this during collectFiles
jakebailey Dec 15, 2025
614dafd
Merge branch 'main' into jabaile/package-deduping
jakebailey Dec 15, 2025
cae2fba
Merge branch 'main' into jabaile/package-deduping
jakebailey Dec 16, 2025
34155c9
Remove _
jakebailey Dec 17, 2025
68627e3
Add test
jakebailey Dec 17, 2025
2bb7300
Use correct path for module error
jakebailey Dec 17, 2025
b966d60
Merge branch 'main' into jabaile/package-deduping
jakebailey Jan 8, 2026
9d0f1d2
Add new failing test that strada passes
jakebailey Jan 8, 2026
a6956ef
Do not extra load files
jakebailey Jan 8, 2026
15e0d64
Rando newline
jakebailey Jan 8, 2026
8cbc494
Greatly simplify
jakebailey Jan 8, 2026
2744db9
Further simplifications
jakebailey Jan 8, 2026
bb3e995
Merge branch 'main' into jabaile/package-deduping
jakebailey Jan 9, 2026
31b3a1e
Use a set instead
jakebailey Jan 9, 2026
a05a21e
oops
jakebailey Jan 9, 2026
ce80a21
Switch to --deduplicatePackages, default true
jakebailey Jan 9, 2026
013aa78
Merge branch 'main' into jabaile/package-deduping
jakebailey Jan 9, 2026
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
2 changes: 1 addition & 1 deletion internal/checker/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -14867,7 +14867,7 @@ func (c *Checker) resolveExternalModule(location *ast.Node, moduleReference stri
return c.getMergedSymbol(sourceFile.Symbol)
}
if errorNode != nil && moduleNotFoundError != nil && !isSideEffectImport(errorNode) {
c.error(errorNode, diagnostics.File_0_is_not_a_module, sourceFile.FileName())
c.error(errorNode, diagnostics.File_0_is_not_a_module, resolvedModule.ResolvedFileName)
}
return nil
}
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
60 changes: 60 additions & 0 deletions internal/compiler/filesparser.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,17 @@ func (w *filesParser) getProcessedFiles(loader *fileLoader) processedFiles {
var sourceFilesFoundSearchingNodeModules collections.Set[tspath.Path]
libFilesMap := make(map[tspath.Path]*LibFile, libFileCount)

var sourceFileToPackageName map[tspath.Path]string
var redirectTargetsMap map[tspath.Path][]string
var deduplicatedPathMap map[tspath.Path]tspath.Path
var packageIdToCanonicalPath map[module.PackageId]tspath.Path
if !loader.opts.Config.CompilerOptions().DisablePackageDeduplication.IsTrue() {
sourceFileToPackageName = make(map[tspath.Path]string, totalFileCount)
redirectTargetsMap = make(map[tspath.Path][]string)
deduplicatedPathMap = make(map[tspath.Path]tspath.Path)
packageIdToCanonicalPath = make(map[module.PackageId]tspath.Path)
}

var collectFiles func(tasks []*parseTask, seen map[*parseTaskData]string)
collectFiles = func(tasks []*parseTask, seen map[*parseTaskData]string) {
for _, task := range tasks {
Expand Down Expand Up @@ -331,6 +342,36 @@ func (w *filesParser) getProcessedFiles(loader *fileLoader) processedFiles {
for _, trace := range task.resolutionsTrace {
loader.opts.Host.Trace(trace.Message, trace.Args...)
}

if packageIdToCanonicalPath != nil {
for _, resolution := range task.resolutionsInFile {
Copy link
Member Author

Choose a reason for hiding this comment

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

I don't particularly like that this loop is like this, versus having the package ID before collect and then deduping with it, but there are some weird ordering constraints here

Copy link
Member Author

Choose a reason for hiding this comment

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

I stared at this for a while and figured out how to actually make it behave the way I wanted without this loop

Copy link
Member Author

Choose a reason for hiding this comment

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

Basically, this loop was working around the fact that packages get their IDs from incoming edges, so there was a race. Now it's way less weird

if !resolution.IsResolved() {
continue
}
pkgId := resolution.PackageId
if pkgId.Name == "" {
continue
}
resolvedPath := loader.toPath(resolution.ResolvedFileName)
packageName := pkgId.PackageName()

if canonical, exists := packageIdToCanonicalPath[pkgId]; exists {
if _, alreadyRecorded := sourceFileToPackageName[resolvedPath]; !alreadyRecorded {
sourceFileToPackageName[resolvedPath] = packageName
if resolvedPath != canonical {

deduplicatedPathMap[resolvedPath] = canonical
redirectTargetsMap[canonical] = append(redirectTargetsMap[canonical], resolution.ResolvedFileName)
}
}
} else {
packageIdToCanonicalPath[pkgId] = resolvedPath
sourceFileToPackageName[resolvedPath] = packageName
deduplicatedPathMap[resolvedPath] = resolvedPath
}
}
}

if subTasks := task.subTasks; len(subTasks) > 0 {
collectFiles(subTasks, seen)
}
Expand Down Expand Up @@ -408,6 +449,22 @@ func (w *filesParser) getProcessedFiles(loader *fileLoader) processedFiles {
}
}

if deduplicatedPathMap != nil {
for duplicatePath, canonicalPath := range deduplicatedPathMap {
if duplicatePath != canonicalPath {
if canonicalFile, ok := filesByPath[canonicalPath]; ok {
filesByPath[duplicatePath] = canonicalFile
}
}
}
allFiles = slices.DeleteFunc(allFiles, func(f *ast.SourceFile) bool {
if canonicalPath, ok := deduplicatedPathMap[f.Path()]; ok {
return f.Path() != canonicalPath
}
return false
})
}

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

Expand Down
13 changes: 11 additions & 2 deletions internal/compiler/program.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,10 @@ 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]
}

// gets the original file that was included in program
Expand Down Expand Up @@ -241,6 +242,14 @@ func (p *Program) UpdateProgram(changedFilePath tspath.Path, newHost CompilerHos
if !canReplaceFileInProgram(oldFile, newFile) {
return NewProgram(newOpts), false
}
// 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 _, ok := p.deduplicatedPathMap[changedFilePath]; ok {
Copy link
Member Author

Choose a reason for hiding this comment

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

On second reading, I think this is wrong; this mapping is always populated even if something was not deduped.

I will have to redo this to instead build a list of files that are actually a part of deduping, I think. Or, not bother. I have not written a test for this case, so...

// File is either a canonical file or a redirect target; either way, need full rebuild
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")
}
8 changes: 4 additions & 4 deletions internal/module/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -985,7 +985,7 @@ func (r *resolutionState) loadModuleFromSpecificNodeModulesDirectory(ext extensi
}

if fromDirectory := r.loadNodeModuleFromDirectoryWorker(ext, candidate, !nodeModulesDirectoryExists, packageInfo); !fromDirectory.shouldContinueSearching() {
fromDirectory.packageId = r.getPackageId(packageDirectory, packageInfo)
fromDirectory.packageId = r.getPackageId(fromDirectory.path, packageInfo)
return fromDirectory
}
}
Expand All @@ -994,12 +994,12 @@ func (r *resolutionState) loadModuleFromSpecificNodeModulesDirectory(ext extensi
loader := func(extensions extensions, candidate string, onlyRecordFailures bool) *resolved {
if rest != "" || !r.esmMode {
if fromFile := r.loadModuleFromFile(extensions, candidate, onlyRecordFailures); !fromFile.shouldContinueSearching() {
fromFile.packageId = r.getPackageId(packageDirectory, packageInfo)
fromFile.packageId = r.getPackageId(fromFile.path, packageInfo)
return fromFile
}
}
if fromDirectory := r.loadNodeModuleFromDirectoryWorker(extensions, candidate, onlyRecordFailures, packageInfo); !fromDirectory.shouldContinueSearching() {
fromDirectory.packageId = r.getPackageId(packageDirectory, packageInfo)
fromDirectory.packageId = r.getPackageId(fromDirectory.path, packageInfo)
return fromDirectory
}
// !!! this is ported exactly, but checking for null seems wrong?
Expand All @@ -1009,7 +1009,7 @@ func (r *resolutionState) loadModuleFromSpecificNodeModulesDirectory(ext extensi
// EsmMode disables index lookup in `loadNodeModuleFromDirectoryWorker` generally, however non-relative package resolutions still assume
// a default `index.js` entrypoint if no `main` or `exports` are present
if indexResult := r.loadModuleFromFile(extensions, tspath.CombinePaths(candidate, "index.js"), onlyRecordFailures); !indexResult.shouldContinueSearching() {
indexResult.packageId = r.getPackageId(packageDirectory, packageInfo)
indexResult.packageId = r.getPackageId(indexResult.path, packageInfo)
return indexResult
}
}
Expand Down
8 changes: 8 additions & 0 deletions internal/tsoptions/declscompiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,14 @@ var commonOptionsWithBuild = []*CommandLineOption{
DefaultValueDescription: false,
// Not setting affectsSemanticDiagnostics or affectsBuildInfo because we dont want all diagnostics to go away, its handled in builder
},
{
Name: "disablePackageDeduplication",
Kind: CommandLineOptionTypeBoolean,
Category: diagnostics.Type_Checking,
Description: diagnostics.Disable_deduplication_of_packages_with_the_same_name_and_version,
DefaultValueDescription: false,
AffectsProgramStructure: true,
},
{
Name: "noEmit",
Kind: CommandLineOptionTypeBoolean,
Expand Down
2 changes: 2 additions & 0 deletions internal/tsoptions/parsinghelpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ func parseCompilerOptions(key string, value any, allOptions *core.CompilerOption
allOptions.DisableSolutionSearching = ParseTristate(value)
case "disableReferencedProjectLoad":
allOptions.DisableReferencedProjectLoad = ParseTristate(value)
case "disablePackageDeduplication":
allOptions.DisablePackageDeduplication = ParseTristate(value)
case "declarationMap":
allOptions.DeclarationMap = ParseTristate(value)
case "declaration":
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/src/a.ts(5,3): error TS2345: Argument of type 'import("/node_modules/c/node_modules/x/index").default' is not assignable to parameter of type 'import("/node_modules/a/node_modules/x/index").default'.
Types have separate declarations of a private property 'x'.


==== /src/a.ts (1 errors) ====
import { a } from "a";
import { b } from "b";
import { c } from "c";
a(b); // Works
a(c); // Error, these are from different versions of the library.
~
!!! error TS2345: Argument of type 'import("/node_modules/c/node_modules/x/index").default' is not assignable to parameter of type 'import("/node_modules/a/node_modules/x/index").default'.
!!! error TS2345: Types have separate declarations of a private property 'x'.

==== /node_modules/a/index.d.ts (0 errors) ====
import X from "x";
export function a(x: X): void;

==== /node_modules/a/node_modules/x/index.d.ts (0 errors) ====
export default class X {
private x: number;
}

==== /node_modules/a/node_modules/x/package.json (0 errors) ====
{ "name": "x", "version": "1.2.3" }

==== /node_modules/b/index.d.ts (0 errors) ====
import X from "x";
export const b: X;

==== /node_modules/b/node_modules/x/index.d.ts (0 errors) ====
content not parsed

==== /node_modules/b/node_modules/x/package.json (0 errors) ====
{ "name": "x", "version": "1.2.3" }

==== /node_modules/c/index.d.ts (0 errors) ====
import X from "x";
export const c: X;

==== /node_modules/c/node_modules/x/index.d.ts (0 errors) ====
export default class X {
private x: number;
}

==== /node_modules/c/node_modules/x/package.json (0 errors) ====
{ "name": "x", "version": "1.2.4" }

Loading