Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 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
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
10 changes: 9 additions & 1 deletion internal/compiler/fileloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,12 @@ 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
// 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 Expand Up @@ -189,6 +194,7 @@ func (p *fileLoader) resolveAutomaticTypeDirectives(containingFileName string) (
kind: fileIncludeKindAutomaticTypeDirectiveFile,
data: &automaticTypeDirectiveFileData{name, resolved.PackageId},
},
packageId: resolved.PackageId,
})
}
}
Expand Down Expand Up @@ -359,6 +365,7 @@ func (p *fileLoader) resolveTypeReferenceDirectives(t *parseTask) {
increaseDepth: resolved.IsExternalLibraryImport,
elideOnDepth: false,
includeReason: includeReason,
packageId: resolved.PackageId,
}, nil)
} else {
t.processingDiagnostics = append(t.processingDiagnostics, &processingDiagnostic{
Expand Down Expand Up @@ -465,6 +472,7 @@ func (p *fileLoader) resolveImportsAndModuleAugmentations(t *parseTask) {
synthetic: core.IfElse(importIndex < 0, entry, nil),
},
},
packageId: resolvedModule.PackageId,
}, nil)
}
}
Expand Down
45 changes: 42 additions & 3 deletions internal/compiler/filesparser.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type parseTask struct {
startedSubTasks bool
isForAutomaticTypeDirective bool
includeReason *FileIncludeReason
packageId module.PackageId

metadata ast.SourceFileMetaData
resolutionsInFile module.ModeAwareCache[*module.ResolvedModule]
Expand Down Expand Up @@ -164,6 +165,7 @@ type resolvedRef struct {
increaseDepth bool
elideOnDepth bool
includeReason *FileIncludeReason
packageId module.PackageId
}

func (t *parseTask) addSubTask(ref resolvedRef, libFile *LibFile) {
Expand All @@ -174,6 +176,7 @@ func (t *parseTask) addSubTask(ref resolvedRef, libFile *LibFile) {
increaseDepth: ref.increaseDepth,
elideOnDepth: ref.elideOnDepth,
includeReason: ref.includeReason,
packageId: ref.packageId,
}
t.subTasks = append(t.subTasks, subTask)
}
Expand All @@ -190,6 +193,7 @@ type parseTaskData struct {
mu sync.Mutex
lowestDepth int
startedSubTasks bool
packageId module.PackageId
}

func (w *filesParser) parse(loader *fileLoader, tasks []*parseTask) {
Expand Down Expand Up @@ -220,6 +224,11 @@ func (w *filesParser) start(loader *fileLoader, tasks []*parseTask, depth int) {
}
}

// Propagate packageId to data if we have one and data doesn't yet
if data.packageId.Name == "" && task.packageId.Name != "" {
data.packageId = task.packageId
}

currentDepth := core.IfElse(task.increaseDepth, depth+1, depth)
if currentDepth < data.lowestDepth {
// If we're seeing this task at a lower depth than before,
Expand Down Expand Up @@ -283,6 +292,15 @@ func (w *filesParser) getProcessedFiles(loader *fileLoader) processedFiles {
var sourceFilesFoundSearchingNodeModules collections.Set[tspath.Path]
libFilesMap := make(map[tspath.Path]*LibFile, libFileCount)

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() {
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,8 +349,23 @@ func (w *filesParser) getProcessedFiles(loader *fileLoader) processedFiles {
for _, trace := range task.resolutionsTrace {
loader.opts.Host.Trace(trace.Message, trace.Args...)
}
if subTasks := task.subTasks; len(subTasks) > 0 {
collectFiles(subTasks, seen)

var existingCanonicalPath tspath.Path
if packageIdToCanonicalPath != nil && data.packageId.Name != "" {
if canonical, exists := packageIdToCanonicalPath[data.packageId]; exists {
redirectTargetsMap[canonical] = append(redirectTargetsMap[canonical], task.normalizedFilePath)
deduplicatedPathMap[task.path] = canonical
existingCanonicalPath = canonical
} else {
packageIdToCanonicalPath[data.packageId] = task.path
deduplicatedPathMap[task.path] = task.path
}
}

if existingCanonicalPath == "" {
if subTasks := task.subTasks; len(subTasks) > 0 {
collectFiles(subTasks, seen)
}
}

// Exclude automatic type directive tasks from include reason processing,
Expand All @@ -350,6 +383,10 @@ func (w *filesParser) getProcessedFiles(loader *fileLoader) processedFiles {
continue
}
file := task.file
if existingCanonicalPath != "" {
file = filesByPath[existingCanonicalPath]
}

path := task.path

if len(task.processingDiagnostics) > 0 {
Expand All @@ -365,7 +402,7 @@ func (w *filesParser) getProcessedFiles(loader *fileLoader) processedFiles {
if task.libFile != nil {
libFiles = append(libFiles, file)
libFilesMap[path] = task.libFile
} else {
} else if existingCanonicalPath == "" {
files = append(files, file)
}
filesByPath[path] = file
Expand Down Expand Up @@ -424,6 +461,8 @@ func (w *filesParser) getProcessedFiles(loader *fileLoader) processedFiles {
missingFiles: missingFiles,
includeProcessor: includeProcessor,
outputFileToProjectReferenceSource: outputFileToProjectReferenceSource,
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 {
// 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
Loading