@@ -102,6 +102,53 @@ const BATCH_BODY_BYTE_CAP: usize = 256 * 1024;
102102/// authenticated API and [`DEFAULT_PROXY_BATCH_SIZE`] on the public proxy.
103103/// Floored at 1: `--batch-size 0` is otherwise unvalidated and would make
104104/// the chunking below panic, so it degrades to one-package batches.
105+ /// `purls` plus, for each lockfile-only PyPI purl among them, its other
106+ /// PEP 440 spellings of the same release (#604), deduplicated in order.
107+ fn with_pypi_equivalents ( purls : & [ String ] , lockfile_only : & HashSet < PurlKey > ) -> Vec < String > {
108+ let mut out = purls. to_vec ( ) ;
109+ let mut seen: HashSet < PurlKey > = purls. iter ( ) . map ( |p| PurlKey :: new ( p) ) . collect ( ) ;
110+ for purl in purls {
111+ if !lockfile_only_contains ( lockfile_only, purl) {
112+ continue ;
113+ }
114+ for spelling in socket_patch_core:: utils:: purl_key:: pypi_equivalent_purls ( purl) {
115+ if seen. insert ( PurlKey :: new ( & spelling) ) {
116+ out. push ( spelling) ;
117+ }
118+ }
119+ }
120+ out
121+ }
122+
123+ /// Mark each API purl that names a lockfile-only PyPI pin under another
124+ /// PEP 440 spelling (`@1.16.0` for a lock's `@1.16`, #604) as lockfile-only
125+ /// too, so the `notInstalled` flag, the `[NOT INSTALLED]` marker and the
126+ /// vendored baseline pre-check treat it as the package it is. A spelling an
127+ /// installed copy already carries is left alone.
128+ fn adopt_pypi_equivalents (
129+ packages : & [ BatchPackagePatches ] ,
130+ scanned : & [ String ] ,
131+ lockfile_only : & mut HashSet < PurlKey > ,
132+ ) {
133+ let scanned_keys: HashSet < PurlKey > = scanned. iter ( ) . map ( |p| PurlKey :: new ( p) ) . collect ( ) ;
134+ let lock_only_pypi: Vec < & String > = scanned
135+ . iter ( )
136+ . filter ( |p| p. starts_with ( "pkg:pypi/" ) && lockfile_only_contains ( lockfile_only, p) )
137+ . collect ( ) ;
138+ for pkg in packages {
139+ let key = PurlKey :: new ( & pkg. purl ) ;
140+ if scanned_keys. contains ( & key) {
141+ continue ;
142+ }
143+ if lock_only_pypi
144+ . iter ( )
145+ . any ( |lock| socket_patch_core:: utils:: purl_key:: pypi_same_release ( lock, & pkg. purl ) )
146+ {
147+ lockfile_only. insert ( key) ;
148+ }
149+ }
150+ }
151+
105152fn effective_batch_size ( requested : Option < usize > , use_public_proxy : bool ) -> usize {
106153 requested
107154 . unwrap_or ( if use_public_proxy {
@@ -1829,7 +1876,10 @@ async fn run_scan(
18291876 // that have NO installed copy (fresh clone, partial install). They join
18301877 // discovery and are flagged "not yet installed". Scoped to the crawled
18311878 // ecosystems.
1832- let lockfile_only = lockfile_supplement ( & ctx, & all_crawled, crawl_scope) . await ;
1879+ let mut lockfile_only = lockfile_supplement ( & ctx, & all_crawled, crawl_scope) . await ;
1880+ // Counted once: #604 adds the API's spellings of lockfile-only PyPI pins
1881+ // to `lockfile_only.purls` after the batch query.
1882+ let lockfile_only_count = lockfile_only. purls . len ( ) ;
18331883 // Unsupported layouts and malformed binary Bun locks, kept on empty
18341884 // scans too: an unreadable graph is not evidence of no dependencies.
18351885 let mut layout_refusals = unsupported_layout_warnings ( & lockfile_only. unsupported ) ;
@@ -2266,8 +2316,8 @@ async fn run_scan(
22662316 plural( package_count, "package" , "packages" )
22672317 ) ) ;
22682318 if human {
2269- if !lockfile_only . purls . is_empty ( ) {
2270- eprintln ! ( "{}" , render:: lockfile_only_note( lockfile_only . purls . len ( ) ) ) ;
2319+ if lockfile_only_count > 0 {
2320+ eprintln ! ( "{}" , render:: lockfile_only_note( lockfile_only_count ) ) ;
22712321 }
22722322 print_layout_refusals ( & layout_refusals, args. common . silent ) ;
22732323 policy. print_warnings ( args. common . silent ) ;
@@ -2276,7 +2326,12 @@ async fn run_scan(
22762326 // Query API in batches
22772327 let mut all_packages_with_patches: Vec < BatchPackagePatches > = Vec :: new ( ) ;
22782328 let mut can_access_paid_patches = false ;
2279- let chunks: Vec < & [ String ] > = batch_chunks ( & all_purls, batch_size, BATCH_BODY_BYTE_CAP ) ;
2329+ // #604: a lockfile-only PyPI pin is spelled as the user wrote it
2330+ // (`six==1.16` → `@1.16`) while the API keys the release as the
2331+ // registry published it (`@1.16.0`); pip treats both as one release
2332+ // (PEP 440). Ask for its equivalent spellings too.
2333+ let query_purls = with_pypi_equivalents ( & all_purls, & lockfile_only. purls ) ;
2334+ let chunks: Vec < & [ String ] > = batch_chunks ( & query_purls, batch_size, BATCH_BODY_BYTE_CAP ) ;
22802335 let total_batches = chunks. len ( ) ;
22812336 let mut batch_error_count = 0usize ;
22822337 let mut last_batch_error: Option < String > = None ;
@@ -2387,6 +2442,13 @@ async fn run_scan(
23872442 // drives the table, the `--json` `packages` array and the apply order,
23882443 // which operators diff across runs.
23892444 all_packages_with_patches. sort_by ( |a, b| a. purl . cmp ( & b. purl ) ) ;
2445+ // #604: a patch the API returned under an equivalent spelling of a
2446+ // lockfile-only PyPI pin is that lockfile-only package.
2447+ adopt_pypi_equivalents (
2448+ & all_packages_with_patches,
2449+ & all_purls,
2450+ & mut lockfile_only. purls ,
2451+ ) ;
23902452
23912453 // If every batch errored, surface a full scan failure rather than
23922454 // silently reporting zero patches.
@@ -2401,7 +2463,7 @@ async fn run_scan(
24012463 "status" : "error" ,
24022464 "error" : { "code" : API_BATCH_FAILED , "message" : err } ,
24032465 "scannedPackages" : package_count,
2404- "lockfileOnlyPackages" : lockfile_only . purls . len ( ) ,
2466+ "lockfileOnlyPackages" : lockfile_only_count ,
24052467 "packagesWithPatches" : 0 ,
24062468 "totalPatches" : 0 ,
24072469 "freePatches" : 0 ,
@@ -2481,7 +2543,7 @@ async fn run_scan(
24812543 let mut result = serde_json:: json!( {
24822544 "status" : "success" ,
24832545 "scannedPackages" : package_count,
2484- "lockfileOnlyPackages" : lockfile_only . purls . len ( ) ,
2546+ "lockfileOnlyPackages" : lockfile_only_count ,
24852547 "packagesWithPatches" : all_packages_with_patches. len( ) ,
24862548 "totalPatches" : total_patches,
24872549 "freePatches" : free_patches,
0 commit comments