Recommend the smallest upgrade that clears an advisory, not the highest

The rollup took the highest fix version across a component's advisories, which
is wrong whenever an advisory is patched on more than one release branch. nanoid
is fixed on both 3.3.x and 5.x, and it is transitive under a postcss range of
^3.3.11 that no 5.x release can satisfy - so the recommendation was unreachable
rather than merely large.

Per advisory the target is now the lowest fix above the version in use; per
component, the highest of those. Fixes on older branches are skipped rather than
offered as a downgrade.
This commit is contained in:
2026-08-29 17:24:52 -04:00
parent d49b421914
commit 7ddfe4d3ef
2 changed files with 61 additions and 8 deletions

View File

@@ -274,21 +274,43 @@ def version_key(version):
return parts
def upgrade_plan(findings):
"""[(component, version, count)] - one row per component that has a fix.
def minimal_fix(fixedin, current):
"""The SMALLEST published fix above the version in use, or None.
The highest fix version across that component's advisories, because
upgrading once to the top of the list clears the ones below it.
An advisory patched on several release branches lists a fix per branch:
nanoid's is fixed in both 3.3.18 and 5.1.16. Taking the highest recommends
a major upgrade when a patch on the current branch would do - and where the
package is transitive, that major may not even be reachable, because the
parent's semver range forbids it. The smallest fix above the current
version is the one that actually clears the advisory.
Fixes at or below the current version belong to older branches and are
ignored; recommending one would be a downgrade.
"""
above = [v for v in fixedin if version_key(v) > version_key(current)]
if not above:
return None
return min(above, key=version_key)
def upgrade_plan(findings):
"""[(component, version, count, major)] - one row per component with a fix.
Per advisory, the minimal upgrade that clears it; per component, the
highest of those, since one upgrade has to satisfy every advisory against
it.
"""
by_component = {}
for f in findings:
if f['allowlisted'] or not f['fixedin']:
continue
target = minimal_fix(f['fixedin'], f['version'])
if target is None:
continue
name = f['component']
best, count, current = by_component.get(name, (None, 0, f['version']))
for version in f['fixedin']:
if best is None or version_key(version) > version_key(best):
best = version
if best is None or version_key(target) > version_key(best):
best = target
by_component[name] = (best, count + 1, current)
rows = []
@@ -337,7 +359,7 @@ def report(findings, blocking):
if ship:
upgrades = upgrade_plan(ship)
if upgrades:
print('\nWHAT TO UPGRADE - shipped components, highest fix seen')
print('\nWHAT TO UPGRADE - shipped components, smallest upgrade that clears them')
for component, version, count, major in upgrades:
note = ' MAJOR - check for breaking changes' if major else ''
print(' %-30s -> %-12s clears %-3d%s'