Recommend the smallest upgrade that clears an advisory, not the highest
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Successful in 4m18s

The rollup took the highest fix version across a component's advisories. That
is wrong whenever an advisory is patched on more than one release branch.

nanoid is the case that exposed it. Both advisories against it are fixed on two
branches - 3.3.16 and 5.1.16, 3.3.18 and 5.1.6 - and the rollup recommended
5.1.16 with a MAJOR warning. But nanoid is transitive, pulled in by postcss at
`^3.3.11`, a range no 5.x release can satisfy. The recommendation was not merely
a bigger job than necessary, it was unreachable: the actual target is 3.3.18, a
patch inside the existing range.

Per advisory the target is now the lowest published fix above the version in
use; per component it is the highest of those, since one upgrade has to satisfy
every advisory against it. Fixes on older branches are skipped rather than
offered as a downgrade - axios advisories list 0.30.x fixes that would have been
a downgrade from 1.13.2.

This also gives the MAJOR label meaning. It now fires only when no fix exists on
the current branch, rather than whenever some branch happened to be higher.
This commit is contained in:
cproudlock
2026-08-29 17:24:37 -04:00
parent c4e2db4755
commit 1cc2a6b782
3 changed files with 74 additions and 14 deletions

View File

@@ -142,6 +142,37 @@ class TestUpgradePlan:
findings = [self._finding('nanoid 3.3.11', '3.3.11', ['5.1.16'])]
assert scan.upgrade_plan(findings)[0][3] is True
def test_a_branch_patch_beats_a_major_on_the_same_advisory(self, scan):
# The real case this was found on: nanoid is fixed in BOTH 3.3.18 and
# 5.1.16, and it is transitive under a postcss range of ^3.3.11 - so
# 5.x is not merely a bigger job, it is unreachable. Recommending the
# highest fix sent somebody to a major upgrade they could not make.
findings = [self._finding('nanoid 3.3.11', '3.3.11',
['3.3.18', '5.1.16'])]
component, version, _count, major = scan.upgrade_plan(findings)[0]
assert version == '3.3.18'
assert major is False
def test_component_target_satisfies_every_advisory_against_it(self, scan):
# Two advisories, minimal fixes 3.3.16 and 3.3.18: the row must be the
# higher, or the upgrade clears one and leaves the other.
findings = [
self._finding('nanoid 3.3.11', '3.3.11', ['3.3.16', '5.1.16']),
self._finding('nanoid 3.3.11', '3.3.11', ['3.3.18', '5.1.6']),
]
assert scan.upgrade_plan(findings)[0][1] == '3.3.18'
def test_a_fix_on_an_older_branch_is_never_a_downgrade(self, scan):
# axios advisories list fixes on the 0.30.x line too; on 1.13.2 those
# are behind us and must not be recommended.
findings = [self._finding('axios 1.13.2', '1.13.2',
['0.30.2', '1.15.2'])]
assert scan.upgrade_plan(findings)[0][1] == '1.15.2'
def test_only_older_branch_fixes_means_no_row(self, scan):
findings = [self._finding('axios 1.13.2', '1.13.2', ['0.30.2'])]
assert scan.upgrade_plan(findings) == []
def test_a_patch_bump_is_not_flagged(self, scan):
findings = [self._finding('pyjwt 2.12.1', '2.12.1', ['2.13.0'])]
assert scan.upgrade_plan(findings)[0][3] is False