Skip to content

Commit d18d2ae

Browse files
committed
Fix bug in yarn-cling
1 parent ab18358 commit d18d2ae

File tree

7 files changed

+154
-91
lines changed

7 files changed

+154
-91
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/@aws-cdk/yarn-cling/lib/hoisting.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,16 @@ import { iterDeps, isPackage, type PackageLockFile, type PackageLockTree } from
44
/**
55
* Hoist package-lock dependencies in-place
66
*
7-
* This happens in two phases:
7+
* Packages are declared in two different roles here:
8+
*
9+
* - "requires" indicates where a package is consumed
10+
* - "dependencies" indicates where a package is provided; it will be available
11+
* to the package it is provided under, as well as any of its children.
12+
*
13+
* This function manipulates the "dependencies" part of the package tree, minimizing
14+
* the occurrences of packages in "dependencies" while keeping all "requires" satified.
15+
*
16+
* This happens by applying two basic operations:
817
*
918
* 1) Move every package into the parent scope (as long as it introduces no conflicts).
1019
* Leave "moved" markers to indicate that a package used to be there and no
@@ -63,22 +72,31 @@ export function renderTree(tree: PackageLockTree): string[] {
6372

6473
export function _addTombstones<A extends PackageLockTree>(root: A): A {
6574
let tree = structuredClone(root);
66-
recurse(tree);
75+
recurse(tree, [tree]);
6776
return tree;
6877

69-
function recurse(node: PackageLockTree) {
70-
// For every node, all the packages they 'requires' should be in 'dependencies'.
78+
function recurse(nodeToCheck: PackageLockTree, rootPathToAdd: PackageLockTree[]) {
79+
// Rootpath is ordered deep -> shallow.
80+
81+
// For every node, all the packages they or any of their children 'requires' should be in 'dependencies'.
7182
// If it's not in 'dependencies', that must mean its at a higher level already, so we put
7283
// the 'moved' tombstone in to make sure we don't accidentally replace this package with a different version.
73-
for (const name of Object.keys(node.requires ?? {})) {
74-
if (!node.dependencies?.[name]) {
75-
node.dependencies = node.dependencies ?? {};
76-
node.dependencies[name] = 'moved';
84+
// Also add 'moved' to all of its parents, until we find a node that has it in 'dependencies'.
85+
for (const name of Object.keys(nodeToCheck.requires ?? {})) {
86+
87+
// For every dependency in 'nodeToCheck', add 'moved' to 'depend. As soon as we find
88+
// the dependency provided declared anywhere, we stop.
89+
for (const nodeToAdd of rootPathToAdd) {
90+
if (nodeToAdd.dependencies?.[name]) {
91+
break;
92+
}
93+
nodeToAdd.dependencies = nodeToAdd.dependencies ?? {};
94+
nodeToAdd.dependencies[name] = 'moved';
7795
}
7896
}
7997

80-
for (const [_, dep] of iterDeps(node)) {
81-
recurse(dep);
98+
for (const [_, dep] of iterDeps(nodeToCheck)) {
99+
recurse(dep, [dep, ...rootPathToAdd]);
82100
}
83101
}
84102
}

packages/@aws-cdk/yarn-cling/lib/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ class PackageGraphBuilder {
8787
}
8888

8989
/**
90-
* Render the tree by starting from the root keys and recursing, pushing every package as high as it can
90+
* Render the tree by starting from the root keys and recursing.
9191
* go without conflicting
9292
*/
9393
public makeDependencyTree(rootKeys: string[]): Record<string, PackageLockPackage> {

packages/@aws-cdk/yarn-cling/test/hoisting.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,38 @@ test('order of hoisting shouldnt produce a broken situation', () => {
160160
]);
161161
});
162162

163+
test('reproduce hoisting bug', () => {
164+
// GIVEN
165+
let tree = pkgFile({
166+
// Prevent pushing child1 and child2 upwards
167+
child2: pkg('999.999.999'),
168+
child1: pkg('999.999.999'),
169+
170+
// sharedparent doesn't have anything to do with 'conflictdep' itself,
171+
// but has 2 children that depend on conflicting versions.
172+
//
173+
// conflictdep@1 is at the top of the tree already.
174+
// We want to avoid that conflictdep@2 gets pushed up because the
175+
// slot in sharedparent.dependencies['conflictdep'] is "free" (because it
176+
// isn't).
177+
sharedparent: pkg('2.0.0', {
178+
child2: pkg('4.0.0', {
179+
conflictdep: pkg('2.0.0'),
180+
}),
181+
child1: pkg2('3.0.0', {
182+
requires: { conflictdep: '1.0.0' },
183+
}),
184+
}),
185+
conflictdep: pkg('1.0.0'),
186+
});
187+
188+
// WHEN
189+
tree = hoistDependencies(tree);
190+
191+
// THEN -- should not throw
192+
_validateTree(tree);
193+
});
194+
163195
function pkg(version: string, dependencies?: Record<string, PackageLockPackage>): PackageLockPackage {
164196
return {
165197
version,
@@ -170,6 +202,19 @@ function pkg(version: string, dependencies?: Record<string, PackageLockPackage>)
170202
};
171203
}
172204

205+
interface Pkg2Options {
206+
dependencies?: Record<string, PackageLockPackage>;
207+
requires?: Record<string, string>;
208+
}
209+
210+
function pkg2(version: string, options: Pkg2Options): PackageLockPackage {
211+
return {
212+
version,
213+
dependencies: options.dependencies,
214+
requires: options.requires,
215+
};
216+
}
217+
173218
function pkgFile(dependencies?: Record<string, PackageLockPackage>): PackageLockFile {
174219
return {
175220
lockfileVersion: 1,

0 commit comments

Comments
 (0)