feat(output): version resolved-dependency SBOM purls and join vendored licenses - #1327
Merged
Merged
Conversation
…d licenses Resolve each SBOM dependency edge to its versioned coordinate before promotion and graph-edge resolution. SbomInventory now builds a version index (version-less purl identity -> the single versioned purl that shares it, drawn from detected packages, dependency purls, and resolved-package purls) and upgrades an unversioned requirement edge to that sibling. Identities with zero or multiple versioned siblings stay unversioned (honest-unknowns). This closes issue #1320's two static, no-network gaps in one join: - Resolved-dependency components carry the resolved, versioned purl (pkg:npm/leftpad@1.0.0), the join key for downstream enrichment. - A vendored dependency (source/metadata on disk) dedups against the unversioned requirement edge into ONE licensed, versioned component instead of two. Both CycloneDX and SPDX consume the shared inventory, so both benefit; the native ScanCode-compatible JSON is untouched. Graph stays closed (edges canonicalize through the same resolver) and bom-refs stay unique. Regenerated the four examples/sbom documents (ripgrep 114->91, flask 54->47, tokio 51->46 components; express unchanged, no lockfile) and the dependency goldens; all validate against cyclonedx-python-lib and pyspdxtools. Updated ADR 0012 with the amendment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Maxim Stykow <maxim.stykow@gmail.com>
Greptile SummaryUpdates the shared SBOM inventory and SPDX serializers to preserve dependency context and emit resolved package coordinates.
Confidence Score: 5/5The PR appears safe to merge. No blocking failures remain in the fixes associated with the previous review threads. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart LR
A["Detected packages"] --> D["Shared SBOM inventory"]
B["Declared dependency edges"] --> C["Owner-scoped purl resolver"]
B2["Resolved dependency edges"] --> C
C --> D
D --> E["CycloneDX components and graph"]
D --> F["SPDX packages and relationships"]
F --> G["PackageVersion and purl ExternalRef"]
Reviews (4): Last reviewed commit: "fix(output): retain unparseable dependen..." | Re-trigger Greptile |
…ls in SPDX Address two P1 correctness bugs in the version-index join and fold in the deferred SPDX purl/version rendering. P1(a) identity now strips only the version: type, namespace, name, qualifiers, and subpath are preserved (rebuilt through PackageUrl for a canonical, qualifier-sorted key). Variants that differ only by a qualifier (?arch=, ?classifier=, …) or subpath are no longer conflated. P1(b) the version index is per-owner, keyed by (for_package_uid, version-less identity) and built from dependency edges only (not detected packages). An unversioned edge is upgraded only from its own resolved_package purl or a single unambiguous versioned sibling under the SAME owner. A version is never borrowed across owners, and ambiguous or cross-owner cases stay unversioned (honest-unknown). Added regression tests for both: two owners resolving one name to different versions (no cross-assignment, graph stays closed) and qualifier/subpath variants that must not be conflated. SPDX now surfaces the versioned coordinate for every package (subject and promoted dependency): tag-value emits PackageVersion and `ExternalRef: PACKAGE-MANAGER purl <purl>`; RDF emits spdx:versionInfo and a spdx:externalRef (referenceCategory packageManager, referenceType purl). Both validate with pyspdxtools. Regenerated the four examples/sbom and the affected goldens; updated ADR 0012. All four examples (CycloneDX + SPDX) and the goldens pass the official validators. Owner-scoping is stricter than the previous document-wide index, so workspace cross-member deps whose lockfile edges are owned elsewhere now stay honestly unversioned (ripgrep 91->107, flask 47->53, tokio 46->51 components); the graph stays closed (0 dangling) in every document. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Maxim Stykow <maxim.stykow@gmail.com>
A dependency edge with no (or empty) `for_package_uid` previously collapsed to a shared empty-owner key in the version index, so an unversioned ownerless requirement could adopt the sole versioned purl contributed by an unrelated ownerless dependency (e.g. a hoisted dep from another workspace/datafile) — a wrong concrete coordinate. Ownerless edges now neither contribute to nor consult the same-owner sibling index: only edges with a concrete `for_package_uid` build or read it. An ownerless unversioned edge resolves its version solely from its own `resolved_package` purl, otherwise stays unversioned (honest-unknown). Added regression test `ownerless_requirement_does_not_borrow_sibling_version`: two ownerless deps from different contexts (`lib@1.0.0` versioned, bare `lib` requirement with no resolved_package) — the bare one stays unversioned, both components are present, and the graph stays closed. Updated ADR 0012. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Maxim Stykow <maxim.stykow@gmail.com>
…of dropping them The PackageUrl-based version-less identity rebuild made `resolve_edge_purl` return None whenever a dependency's non-empty purl failed to parse, so promotion AND graph construction skipped it — the component and its owner relationship vanished from both CycloneDX and SPDX even though the native output preserves that coordinate (a silent, dishonest inventory loss). A non-empty purl is now never dropped: when it cannot be parsed it cannot be canonicalized or deduped, so it is passed through unchanged as the component coordinate and graph-edge ref. Only a genuinely empty purl is absent, matching prior behavior. The retained component's bom-ref and the edge ref are the same original string, so the graph stays closed. Added regression test `unparseable_purl_is_retained_as_component_and_edge`: a dependency with a non-empty malformed purl still appears as a promoted component with its original coordinate and its owner→dep edge; the graph stays closed with no dangling refs. Updated ADR 0012. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Maxim Stykow <maxim.stykow@gmail.com>
mstykow
added a commit
that referenced
this pull request
Jul 24, 2026
Content already reflected 1.0.2 (versioned dep purls, deduped components, SPDX purl rendering) from #1327; this rolls the embedded version label from 1.0.1 to 1.0.2 (Creator/tools/README) so the stamp matches the output. On-demand regen — no release hook. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Maxim Stykow <maxim.stykow@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
src/output/sbom.rs), so both CycloneDX and SPDX benefit, and SPDX now renders the versioned coordinate as a field. The native ScanCode-compatible JSON is untouched.What changed
SbomInventorybuilds an owner-scoped version index and canonicalizes every dependency edge through it before promotion and graph-edge resolution:pkg:npm/leftpad, from a manifest) is upgraded to its resolved coordinate (pkg:npm/leftpad@1.0.0) — from the edge's ownresolved_package, or from a single unambiguous versioned sibling under the same owner. Identities with zero or multiple versioned candidates stay unversioned (honest-unknowns; Provenant does not guess which version a range resolved to).resolved_packagewhen present, else unset). No more two-components-for-one-dependency.PackageVersion+ExternalRef: PACKAGE-MANAGER purl <purl>, RDFspdx:versionInfo+spdx:externalRef(referenceCategory packageManager, referenceType purl). SPDX previously carried neither.Promotion and edge resolution share one resolver, so the graph stays closed (0 dangling),
bom-ref/dependencies[].refstay unique, and a dep owned by multiple packages remains a single component.Correctness invariants (identity + version resolution)
The version-index identity and resolution are deliberately conservative — an edge only ever adopts a version that is genuinely its own resolution:
PackageUrl, keeping type + namespace + name + qualifiers + subpath. Variants that differ only by a qualifier (arch, classifier, repository) or subpath are never conflated.(owner, identity)from dependency edges only. An unversioned requirement under one owner never inherits a version from a sibling under a different owner (owners can resolve the same package to different versions).for_package_uidneither contribute to nor consult the sibling index — an ownerless edge resolves only from its ownresolved_package, else stays unversioned.PackageUrlcannot parse is passed through unchanged as the component coordinate and graph ref (canonicalization is skipped for it), so a malformed-but-present dependency is never silently erased from the SBOM.Before / after
Repro tree (
package.json+package-lock.json+node_modules/leftpad/package.jsonwith"license": "MIT"), scanned--license --package --strip-root:pkg:npm/leftpad@1.0.0(detected, MIT) andpkg:npm/leftpad(resolved dep, unversioned, no license).pkg:npm/leftpad@1.0.0carrying MIT; edgemyapp → pkg:npm/leftpad@1.0.0; 0 dangling, in both CycloneDX and SPDX.Real examples (
examples/sbom, regenerated). Components drop where an unversioned requirement coalesces onto its resolved/detected versioned sibling under the same owner; the modest deltas reflect the conservative rules above (ambiguous, cross-owner, and ownerless requirements are intentionally left as-is), graph still fully closed:aho-corasick/bstr) correctly stay unversionedIssues
Scope and exclusions
examples/sbomdocuments.How to verify
Build the leftpad repro above and scan
--license --package --strip-root --cyclonedx -(and--spdx-tv -): one licensed, versioned component,myapp DEPENDS_ON leftpad@1.0.0, 0 dangling in both formats.Official-validator gate (blocking): every checked-in output-format fixture and all four
examples/sbom/*/{sbom.cdx.json,sbom.spdx}passcyclonedx-python-lib(CycloneDX 1.3 JSON schema + XSD) andpyspdxtools(SPDX-2.2,.tv→.spdxfirst); live-scan RDF/JSON/XML also validated viascripts/validate_output_format_fixtures.py --provenant-bin ….Regression unit tests in
src/output/sbom.rscover each invariant: qualifier/subpath non-conflation, cross-owner non-inheritance, ownerless non-pooling, and unparseable-purl retention.ScanCode parity
compare-outputsdiffs the native ScanCode-compatible--jsonoutput; this change is confined tosrc/output/sbom.rs, which is imported only by the CycloneDX/SPDX renderers and never by the native writers, so the nativepackages[]/dependencies[]output is byte-identical (verified on the leftpad repro). (ScanCode is not installed in this environment; the conclusion follows structurally from the import graph plus the byte-identical native output.)Expected-output fixture changes
testdata/output-formats/cyclonedx-dependencies-expected.{json,xml},testdata/output-formats/spdx-dependencies-expected.tv(+spdx-licensed/spdx-multi-packagenow carryPackageVersion/ExternalRef), and the four regeneratedexamples/sbom/*documents + READMEs.Creator:/Created:lines like every other SPDX-tv golden (it previously used a rawassert_eq, coupling the fixture to the exact build version). All regenerated fixtures pass the official external validators.🤖 Generated with Claude Code