Skip to content

Commit f312f3c

Browse files
mstykowclaude
andauthored
fix(assembly): report dependencies that resolve to no PURL (#1347)
* fix(assembly): report dependencies that resolve to no PURL Whether a purl-less dependency reached the top level depended on which assembler claimed the datafile: `assemble_one_per_package_data` and the per-identity orphan hoist kept it when it carried `extracted_requirement`, while the sibling engines, the nested merge and every workspace merger filtered on the PURL alone and dropped it. The same manifest was reported differently according to what sat next to it on disk. A dependency the parser could not resolve to a PURL is still a declared dependency when it carries the text it was declared with. Give every assembler one shared `is_reportable_dependency` rule so the outcome no longer depends on the path taken. This also repairs a regression from "stop hoisting a datafile's dependencies twice" (#1338). That change suppressed the unassembled fallback once an assembler had emitted for a datafile — but the assembler it deferred to used the narrower filter, so a `requirements.txt` holding a bare URL requirement went from reporting it (alongside a duplicated sibling) to not reporting it at all. No expected fixture changes: nothing in testdata exercised a purl-less dependency through these paths, so the rule only reaches the cases that were being dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Maxim Stykow <maxim.stykow@gmail.com> * fix(carthage): keep purl-less entries identifiable, and report them Reporting purl-less dependencies surfaced two Cartfile origins that were being dropped: a GitHub Enterprise `git` entry and a `binary` framework manifest. Both are declared dependencies; neither is addressable as a PURL, which is precisely why hiding them is the wrong outcome — they are the ones an SBOM consumer cannot look up anywhere else. A `git` or `binary` entry declared without a version carried nothing addressable at all: no PURL, and `extracted_requirement` held only the version spec, which in that case is absent. Record the source URL there instead, so every entry keeps something that identifies it. `github` entries are unaffected — their PURL is the identity, so the version spec alone remains the requirement. The two scanner contract tests asserted the previous "only PURL-bearing dependencies are hoisted" contract; they now assert that purl-less entries are reported and that each stays identifiable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Maxim Stykow <maxim.stykow@gmail.com> --------- Signed-off-by: Maxim Stykow <maxim.stykow@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7bef3ed commit f312f3c

16 files changed

Lines changed: 138 additions & 29 deletions

src/assembly/cargo_workspace_merge.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,7 @@ fn create_member_packages(
483483
let mut deps: Vec<TopLevelDependency> = resolved_pkg_data
484484
.dependencies
485485
.iter()
486-
.filter(|dep| dep.purl.is_some())
486+
.filter(|dep| super::is_reportable_dependency(dep))
487487
.map(|dep| {
488488
TopLevelDependency::from_dependency(
489489
dep,
@@ -501,7 +501,7 @@ fn create_member_packages(
501501
lock_pkg_data
502502
.dependencies
503503
.iter()
504-
.filter(|dep| dep.purl.is_some())
504+
.filter(|dep| super::is_reportable_dependency(dep))
505505
.map(|dep| {
506506
TopLevelDependency::from_dependency(
507507
dep,
@@ -562,7 +562,7 @@ fn create_root_package(
562562
let mut deps: Vec<TopLevelDependency> = resolved_pkg_data
563563
.dependencies
564564
.iter()
565-
.filter(|dep| dep.purl.is_some())
565+
.filter(|dep| super::is_reportable_dependency(dep))
566566
.map(|dep| {
567567
TopLevelDependency::from_dependency(
568568
dep,
@@ -580,7 +580,7 @@ fn create_root_package(
580580
lock_pkg_data
581581
.dependencies
582582
.iter()
583-
.filter(|dep| dep.purl.is_some())
583+
.filter(|dep| super::is_reportable_dependency(dep))
584584
.map(|dep| {
585585
TopLevelDependency::from_dependency(
586586
dep,
@@ -633,7 +633,7 @@ fn hoist_root_lock_dependencies(
633633
pkg_data
634634
.dependencies
635635
.iter()
636-
.filter(|dep| dep.purl.is_some())
636+
.filter(|dep| super::is_reportable_dependency(dep))
637637
.map(|dep| {
638638
TopLevelDependency::from_dependency(
639639
dep,

src/assembly/dart_workspace_merge.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ pub(super) fn apply_dart_workspace_domain(
145145
root_data
146146
.dependencies
147147
.iter()
148-
.filter(|dep| dep.purl.is_some())
148+
.filter(|dep| super::is_reportable_dependency(dep))
149149
.map(|dependency| {
150150
TopLevelDependency::from_dependency(
151151
dependency,
@@ -256,7 +256,7 @@ fn attribute_shared_lock(
256256
for dependency in lock_data
257257
.dependencies
258258
.iter()
259-
.filter(|dep| dep.purl.is_some())
259+
.filter(|dep| super::is_reportable_dependency(dep))
260260
{
261261
let name = dependency.purl.as_deref().and_then(purl_name);
262262
let owners = candidates.iter().filter(|candidate| {

src/assembly/debian_source_merge.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ pub(super) fn assemble_debian_source_packages(
6666
let dependencies = pkg_data
6767
.dependencies
6868
.iter()
69-
.filter(|dependency| dependency.purl.is_some())
69+
.filter(|dependency| super::is_reportable_dependency(dependency))
7070
.map(|dependency| {
7171
TopLevelDependency::from_dependency(
7272
dependency,

src/assembly/gradle_multiproject.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ fn ensure_gradle_package(
309309
dependencies.extend(
310310
data.dependencies
311311
.iter()
312-
.filter(|dep| dep.purl.is_some())
312+
.filter(|dep| super::is_reportable_dependency(dep))
313313
.map(|dependency| {
314314
TopLevelDependency::from_dependency(
315315
dependency,

src/assembly/mix_umbrella_merge.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,7 @@ fn emit_direct_dependencies(
490490
continue;
491491
}
492492

493-
if dep.purl.is_some() {
493+
if super::is_reportable_dependency(dep) {
494494
dependencies.push(TopLevelDependency::from_dependency(
495495
dep,
496496
manifest_path.to_string(),

src/assembly/mod.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ use std::collections::{BTreeSet, HashMap, HashSet};
4545
use std::path::PathBuf;
4646
use std::sync::LazyLock;
4747

48-
use crate::models::{DatasourceId, FileInfo, Package, PackageUid, TopLevelDependency};
48+
use crate::models::{DatasourceId, Dependency, FileInfo, Package, PackageUid, TopLevelDependency};
4949

5050
pub use assemblers::ASSEMBLERS;
5151

@@ -335,6 +335,21 @@ fn hoist_unassembled_file_dependencies(
335335
dependencies.extend(hoisted);
336336
}
337337

338+
/// Whether a parsed dependency is worth reporting at the top level.
339+
///
340+
/// A dependency the parser could not resolve to a PURL is still a declared
341+
/// dependency as long as it carries the text it was declared with, and
342+
/// `extracted_requirement` is exactly that text. Dropping it discards the only
343+
/// record that the manifest asked for it.
344+
///
345+
/// Every assembler shares this rule. Testing the PURL alone — as most of them
346+
/// used to — made a purl-less dependency's visibility depend on which assembler
347+
/// happened to claim the datafile, so the same manifest reported it or not
348+
/// according to what sat next to it on disk.
349+
pub(super) fn is_reportable_dependency(dependency: &Dependency) -> bool {
350+
dependency.purl.is_some() || dependency.extracted_requirement.is_some()
351+
}
352+
338353
const HOIST_IF_UNOWNED_DATASOURCE_IDS: &[DatasourceId] = &[DatasourceId::PipRequirements];
339354

340355
fn should_hoist_unassembled_dependencies(datasource_id: DatasourceId) -> bool {
@@ -405,7 +420,7 @@ fn assemble_one_per_package_data(
405420
let deps: Vec<TopLevelDependency> = pkg_data
406421
.dependencies
407422
.iter()
408-
.filter(|dep| dep.purl.is_some() || dep.extracted_requirement.is_some())
423+
.filter(|dep| is_reportable_dependency(dep))
409424
.map(|dep| {
410425
TopLevelDependency::from_dependency(
411426
dep,

src/assembly/nested_merge.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ fn assemble_from_indices(
247247
}
248248

249249
for dep in &pkg_data.dependencies {
250-
if dep.purl.is_some() {
250+
if super::is_reportable_dependency(dep) {
251251
pending_dependencies.push(PendingDependency {
252252
dependency: dep.clone(),
253253
datafile_path: datafile_path.clone(),

src/assembly/npm_workspace_merge.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -583,7 +583,7 @@ fn create_member_packages(
583583
let deps: Vec<TopLevelDependency> = pkg_data
584584
.dependencies
585585
.iter()
586-
.filter(|dep| dep.purl.is_some())
586+
.filter(|dep| super::is_reportable_dependency(dep))
587587
.map(|dep| {
588588
TopLevelDependency::from_dependency(
589589
dep,
@@ -621,7 +621,7 @@ fn hoist_root_dependencies(
621621
.find(|pkg| pkg.datasource_id == Some(DatasourceId::NpmPackageJson))
622622
{
623623
for dep in &root_pkg_data.dependencies {
624-
if dep.purl.is_some() {
624+
if super::is_reportable_dependency(dep) {
625625
let mut top_dep = TopLevelDependency::from_dependency(
626626
dep,
627627
root_file.path.clone(),
@@ -683,7 +683,7 @@ fn hoist_root_dependencies(
683683
}
684684

685685
for dep in &pkg_data.dependencies {
686-
if dep.purl.is_some() {
686+
if super::is_reportable_dependency(dep) {
687687
let mut top_dep = TopLevelDependency::from_dependency(
688688
dep,
689689
file.path.clone(),

src/assembly/python_requirements_assign.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ pub fn assign_python_requirements_to_projects(
3636
project_roots: &project_roots,
3737
is_relevant_file: is_requirements_subdir_file,
3838
find_root: find_nearest_project_root,
39-
include_dependency: |dep: &Dependency| dep.purl.is_some(),
39+
include_dependency: |dep: &Dependency| super::is_reportable_dependency(dep),
4040
},
4141
);
4242
}

src/assembly/sibling_merge.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ pub(super) fn assemble_single_sibling_package(
113113
}
114114

115115
for dep in &pkg_data.dependencies {
116-
if dep.purl.is_some() {
116+
if super::is_reportable_dependency(dep) {
117117
pending_dependencies.push(PendingDependency {
118118
dependency: dep.clone(),
119119
datafile_path: datafile_path.clone(),
@@ -241,7 +241,7 @@ pub(super) fn assemble_siblings_per_identity(
241241
pkg_data
242242
.dependencies
243243
.iter()
244-
.filter(|dep| dep.purl.is_some())
244+
.filter(|dep| super::is_reportable_dependency(dep))
245245
.map(|dep| PendingDependency {
246246
dependency: dep.clone(),
247247
datafile_path: datafile_path.clone(),
@@ -297,7 +297,7 @@ pub(super) fn collect_pending_dependencies(
297297
pkg_data
298298
.dependencies
299299
.iter()
300-
.filter(|dep| dep.purl.is_some() || dep.extracted_requirement.is_some())
300+
.filter(|dep| super::is_reportable_dependency(dep))
301301
.cloned()
302302
.map(|dependency| PendingDependency {
303303
dependency,

0 commit comments

Comments
 (0)