Repository navigation
feat(versions): compare each ecosystem with its own version scheme (PEP 440 for PyPI) - #1397
alex-powell wants to merge 1 commit into
Conversation
compareVersions, looksLikeVersion, isPreReleaseVersion, and normalizeAdvisoryVersion keep their names. Passing a PyPI ecosystem selects PEP 440. Other callers stay on semver, including sort and filter callbacks that pass an index. A PyPI advisory whose only fix is 2026.3.post1 is reported. scripts/check-pep440.ts judges a pinned requirements file against OSV and compares a PyPI sample with packaging.
c65cdc5 to
52d1792
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Order-dependent advisory evaluation can miss vulnerable versions, its reference check repeats the error, and scanner fix selection still discards invalid PyPI fixed boundaries.
3 open issues
What changed in this PR
Adds ecosystem-aware version comparison for experimental Python support, using PEP 440 for PyPI while preserving the default semver behavior.
Changes:
- Adds PEP 440 parsing, normalization, ordering, and version-scheme selection.
- Passes ecosystem information through scanner fix selection and offline advisory matching.
- Adds regression tests and a requirements/PyPI validation script without new runtime dependencies.
| File | Description |
|---|---|
tsconfig.test.json |
Includes the validation script in test compilation. |
tests/utils/pep440.test.ts |
Tests parsing, ordering, schemes, and advisory evaluation. |
tests/utils/pep440-helpers.test.ts |
Tests PyPI dispatch through existing version helpers. |
tests/scripts/check-pep440-requirements.test.ts |
Tests requirements parsing and advisory reports. |
tests/scanner/pep440-fix-version.test.ts |
Tests PyPI fix selection and npm compatibility. |
tests/advisory/pep440-local-db.test.ts |
Tests offline PyPI matching and invalid boundaries. |
src/utils/versions/scheme.ts |
Defines the version-scheme contract and parse error. |
src/utils/versions/registry.ts |
Selects the scheme by ecosystem. |
src/utils/versions/pep440.ts |
Implements PEP 440 parsing and comparison. |
src/utils/versions/advisory.ts |
Adds advisory-bound and PyPI event evaluation. |
src/utils/version.ts |
Adds ecosystem-aware dispatch to existing helpers. |
src/scanner.ts |
Uses ecosystem-aware comparison for fix selection. |
src/advisory/local-db.ts |
Applies ecosystem-aware offline range matching. |
scripts/check-pep440.ts |
Adds requirements reports and Python reference comparisons. |
Review effort: Balanced
| if (event.fixed !== undefined) { | ||
| const fixed = boundary(event.fixed); | ||
| if (!fixed) unparsed.push({ field: "fixed", value: event.fixed }); | ||
| else if (pep440Scheme.compare(version, fixed) >= 0) affected = false; |
| elif ver >= parsed: | ||
| affected = False |
| const fixed = normalizeAdvisoryVersion(event.fixed, ecosystem); | ||
| if (!fixed || isPreReleaseVersion(fixed, ecosystem)) continue; |
| process.exit(1); | ||
| } | ||
| const code = requirements !== undefined ? await runRequirements(requirements) : await runSample(); | ||
| process.exit(code); |
sonukapoor
left a comment
There was a problem hiding this comment.
This is careful work, @alex-powell, and the core of it is right. I ran about fifty PEP 440 cases through the parser, covering dev, pre and post ordering, dev-of-pre and dev-of-post, the alternate spellings, implicit post releases, epochs and local labels, and every one matched packaging. On the npm side nothing moves: merged onto current main, the full suite passes with no existing test changed, and scan JSON is identical to main on nest, analog, yarn-berry, cline, vscode and juice-shop offline, and on nest and analog online.
A few things before it goes in.
-
An unreadable fix version still turns into "no fix available". The description says an unreadable PyPI boundary throws, but in the scanner only
introduceddoes. Afixedvalue that cannot be read is dropped, so an advisory whose only fix is2004d(pytz has real ones) reports no fix. That is the exact failure #1401 was filed for. Copilot's comment on that line is right. -
Throwing aborts the whole scan, though, so I would rather this degraded than threw. Nothing catches
UnparseableVersionErroron the online path or through the local advisory source, so once Python is wired in, one odd row (a GIT commit hash in an older offline DB, a VCS pin) stops the entire run. Inlocal-dbthe throw also fires before theintroducedcheck, so a row whose window can never include the version still kills it. You already have the right model injudgeOne, which returns inconclusive. Please do the same in the scanner and the local DB: mark that advisory or package as unreadable, say so in the output, and keep going. That also fixes point 1 the honest way. -
Please move the verification tooling to its own PR.
scripts/check-pep440.tsand most ofsrc/utils/versions/advisory.tsare only used by that script and its tests, butadvisory.tscompiles intodist.versionMatchesAdvisoryBoundsis used nowhere, andschemeForonly in tests. The script was useful for convincing us, and I would like to keep it, separately. Copilot's urllib3 point applies to it too: with events listed out of order, both your evaluator and the Python reference mark2.0.0unaffected, so they agree with each other while both miss it. That needs fixing before the "0 range mismatches" figure means anything. -
One place for "is this PyPI". The same check now lives in
version.ts,scanner.ts,local-db.tsandregistry.ts. Export the one inversion.tsand use it everywhere. -
Pin the npm fallback. Nothing tests that a non-string third argument, such as the index
filterpasses, stays on semver. That is the guarantee npm parity rests on, so it deserves its own test. The comment above it also sayssortpasses an index, which it does not;filterandmapdo.
Small ones: pep440Ecosystem in the scanner sits between versionWithinAffectedWindow's doc comment and that function, so the comment now describes the wrong function. group() in pep440.ts returns its input unchanged. The branch also needs a rebase.
Once 1, 2 and 5 are in and the tooling is split out, I expect to approve this quickly.


Fixes #1401
Refs #672
What changed and why
PyPI versions now order with PEP 440, through the helpers that already live in
src/utils/version.ts.compareVersions,looksLikeVersion,isPreReleaseVersion, andnormalizeAdvisoryVersionkeep their names. A caller that has an ecosystem passes it.PyPIandpypiselect PEP 440. A missing ecosystem, and every other ecosystem, stays on semver.npm callers are unchanged.
sortandfilterstill receive these functions directly. The extra argument ignores the numeric index those callbacks pass, so npm ordering stays the same.The scanner and the offline database pass the ecosystem they already store. A PyPI advisory whose only fix is
2026.3.post1reports that fix. An unreadable PyPI boundary throws, so a miss stays visible.This change is the comparison step. Lockfile parsing, package URLs, exit codes, SBOMs, and the offline database shape are later steps.
Done when
Every form in the table parses, and the two orderings come out right, with tests for each.
tests/utils/pep440-helpers.test.tscalls the helpers with ecosystemPyPI.2.9.0.post02026.3.post11!2.0.04.0.0.dev11.0.0rc11.0.0a12.0.0b21.0.0.post1.dev21.2.3+localafter1.2.31.0.0rc1<1.0.01!0.1.0>2.0.0tests/scanner/pep440-fix-version.test.ts, "reports 2026.3.post1 when that is the only fix", callsfindFirstFixedVersionfor pytz at2026.3and expects2026.3.post1.The full suite passes with no existing test changed.
git diff upstream/main -- tests/lists only new files.npm teston this commit passed 183 suites and 2513 tests. That run is the npm parity check.tests/helpers.test.ts,tests/utils/version-extensions.test.ts,tests/output/parent-update-display.test.ts, andtests/remediation/fix-commands-parent-update-merge.test.tsmatch upstreamc5a5180.No new runtime dependency.
git diff upstream/main -- package.jsonis empty.Checked against PyPI and OSV
Node v22.23.4, from a checkout of this branch.
--requirementsprints JSON for each exact pin. Comments, blank lines, extras, and environment markers are ignored. Each pin is queried on OSV and judged with PEP 440.--samplecompares PyPI release lists and OSV ranges for python-dateutil, pytz, numpy, django, requests, urllib3, jinja2, pillow, cryptography, setuptools, idna, and packaging with packaging 26.3.2.9.0,2.9.0.post0,2026.3,2026.3.post1,1.0rc1,1!2.0,1.22, and1.22.0are checked against the same oracle and are left out of the seen and parsed totals.Re-run on
52d1792.Both parsers reject the same 45 pytz legacy tags,
2004dthrough2013d. The sampled release lists contain no epoch. Epoch ordering is covered by1!2.0in the explicit check and by the unit tests.Verified against a production requirements.txt
I ran
--requirementsagainst the pinnedrequirements.txtof the production Python backend I mentioned in #672, and compared it with pip-audit 2.10.1 on the same file. That run used Node 20.19.2 atc65cdc5.Both flag
opentelemetry-api==1.45.0. This branch reports GHSA-xcq7-p3pf-p88h. pip-audit reports CVE-2026-114603, fixed in 1.45.1. OSV lists them as aliases of the same advisory, so the two results agree. The other 19 pins are unaffected in both.That file has no post, dev, pre-release, epoch, or two-part versions, so this run confirms parity on a real tree. The new forms are covered by the sample run above. The one unusual form is the single-segment date version
pdfminer.six==20260107, which parses.