Skip to content

Spdx analyzer ignores dependsOn and contains relationships — all packages reported as direct top-level dependencies #11647

@willebra

Description

@willebra

Target repository: https://github.com/oss-review-toolkit/ort
Component: Spdx package manager plugin (plugins/package-managers/spdx)
ORT version: 83.0.1


Summary

The Spdx analyzer plugin (SPDX 3.0.1) does not use dependsOn or contains
relationships when building its dependency graph. Every package in the document
is emitted as a flat, direct dependency of the project regardless of its actual
position in the dependency graph. Tools and reports that rely on direct vs.
transitive classification receive no useful depth information.


Environment

Item Value
ORT version 83.0.1
ORT image ghcr.io/oss-review-toolkit/ort:83.0.1
Plugin enabled via .ort.yml with enabled_package_managers: [Spdx]

Steps to reproduce

Save the following minimal SPDX 3.0.1 file as example.spdx.json:

{
  "@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
  "@graph": [
    {
      "type": "CreationInfo",
      "@id": "_:ci",
      "specVersion": "3.0.1",
      "created": "2026-01-01T00:00:00Z",
      "createdBy": ["https://example.org/dep-tree-example/agent/ExampleOrg"],
      "createdUsing": ["https://example.org/dep-tree-example/tool/example-tool"]
    },
    {
      "type": "Organization",
      "spdxId": "https://example.org/dep-tree-example/agent/ExampleOrg",
      "creationInfo": "_:ci",
      "name": "Example Organization"
    },
    {
      "type": "Tool",
      "spdxId": "https://example.org/dep-tree-example/tool/example-tool",
      "creationInfo": "_:ci",
      "name": "example-tool"
    },
    {
      "type": "SpdxDocument",
      "spdxId": "https://example.org/dep-tree-example/document/example",
      "creationInfo": "_:ci",
      "name": "dep-tree-example",
      "rootElement": ["https://example.org/dep-tree-example/sbom/example-image"],
      "profileConformance": ["core", "software"]
    },
    {
      "type": "software_Sbom",
      "spdxId": "https://example.org/dep-tree-example/sbom/example-image",
      "creationInfo": "_:ci",
      "name": "example-image",
      "software_sbomType": ["build"],
      "rootElement": ["https://example.org/dep-tree-example/package/example-image"]
    },
    {
      "type": "software_Package",
      "spdxId": "https://example.org/dep-tree-example/package/example-image",
      "creationInfo": "_:ci",
      "name": "example-image",
      "software_packageVersion": "1.0",
      "software_primaryPurpose": "install"
    },
    {
      "type": "software_Package",
      "spdxId": "https://example.org/dep-tree-example/package/app-a",
      "creationInfo": "_:ci",
      "name": "app-a",
      "software_packageVersion": "2.0",
      "software_primaryPurpose": "install"
    },
    {
      "type": "software_Package",
      "spdxId": "https://example.org/dep-tree-example/package/app-standalone",
      "creationInfo": "_:ci",
      "name": "app-standalone",
      "software_packageVersion": "1.5",
      "software_primaryPurpose": "install"
    },
    {
      "type": "software_Package",
      "spdxId": "https://example.org/dep-tree-example/package/lib-b",
      "creationInfo": "_:ci",
      "name": "lib-b",
      "software_packageVersion": "3.1",
      "software_primaryPurpose": "install"
    },
    {
      "type": "software_Package",
      "spdxId": "https://example.org/dep-tree-example/package/lib-c",
      "creationInfo": "_:ci",
      "name": "lib-c",
      "software_packageVersion": "1.0",
      "software_primaryPurpose": "install"
    },
    {
      "type": "software_Package",
      "spdxId": "https://example.org/dep-tree-example/package/lib-d",
      "creationInfo": "_:ci",
      "name": "lib-d",
      "software_packageVersion": "2.2",
      "software_primaryPurpose": "install"
    },
    {
      "type": "Relationship",
      "spdxId": "https://example.org/dep-tree-example/relationship/sbom-contains",
      "creationInfo": "_:ci",
      "from": "https://example.org/dep-tree-example/sbom/example-image",
      "relationshipType": "contains",
      "to": [
        "https://example.org/dep-tree-example/package/app-a",
        "https://example.org/dep-tree-example/package/app-standalone"
      ]
    },
    {
      "type": "Relationship",
      "spdxId": "https://example.org/dep-tree-example/relationship/app-a-deps",
      "creationInfo": "_:ci",
      "from": "https://example.org/dep-tree-example/package/app-a",
      "relationshipType": "dependsOn",
      "to": [
        "https://example.org/dep-tree-example/package/lib-b",
        "https://example.org/dep-tree-example/package/lib-c"
      ]
    },
    {
      "type": "Relationship",
      "spdxId": "https://example.org/dep-tree-example/relationship/lib-b-deps",
      "creationInfo": "_:ci",
      "from": "https://example.org/dep-tree-example/package/lib-b",
      "relationshipType": "dependsOn",
      "to": [
        "https://example.org/dep-tree-example/package/lib-d"
      ]
    }
  ]
}

The intended dependency graph is:

SBOM
├── app-a (direct)          ← listed in SBOM's contains
│   ├── lib-b (transitive)  ← app-a dependsOn lib-b
│   │   └── lib-d (transitive, depth 2)  ← lib-b dependsOn lib-d
│   └── lib-c (transitive)  ← app-a dependsOn lib-c
└── app-standalone (direct) ← listed in SBOM's contains

Add a .ort.yml next to the file:

analyzer:
  enabled_package_managers:
    - Spdx

Run analysis:

ort analyze -i /path/to/dir -o /path/to/dir/ort-out

Actual behaviour

ORT reports all 6 packages as direct dependencies with no tree structure:

Found 1 project(s) and 6 package(s) in total.
Resolved issues: 0 errors, 0 warnings, 0 hints.
Unresolved issues: 0 errors, 0 warnings, 0 hints.

The dependency_graphs section of the analyzer result YAML:

dependency_graphs:
  SPDX:
    packages:
      - SPDX::app-a:2.0
      - SPDX::app-standalone:1.5
      - SPDX::example-image:1.0
      - SPDX::lib-b:3.1
      - SPDX::lib-c:1.0
      - SPDX::lib-d:2.2
    nodes:
      - {pkg: 1}   # no "dependencies" key on any node
      - {pkg: 2}
      - {pkg: 3}
      - {pkg: 4}
      - {pkg: 5}
      - {}
    scopes:
      ':dep-tree-example::install':
        - {root: 0}   # all 6 packages listed as direct scope roots
        - {root: 1}
        - {root: 2}
        - {root: 3}
        - {root: 4}
        - {root: 5}

Expected behaviour

Only the two packages referenced in the SBOM's contains relationship should
be direct scope entries. Packages reachable exclusively via dependsOn chains
should be transitive, with dependencies populated on their parent nodes:

dependency_graphs:
  SPDX:
    packages:
      - SPDX::app-a:2.0
      - SPDX::app-standalone:1.5
      - SPDX::example-image:1.0
      - SPDX::lib-b:3.1
      - SPDX::lib-c:1.0
      - SPDX::lib-d:2.2
    nodes:
      - pkg: 0   # app-a
        dependencies: [2, 3]     # lib-b, lib-c
      - pkg: 1   # app-standalone (leaf)
      - pkg: 3   # lib-b
        dependencies: [4]        # lib-d
      - pkg: 4   # lib-c (leaf)
      - pkg: 5   # lib-d (leaf)
    scopes:
      ':dep-tree-example::install':
        - {root: 0}   # app-a (direct via contains)
        - {root: 1}   # app-standalone (direct via contains)
        # lib-b, lib-c, lib-d are transitive — not listed as scope roots

Root cause

In plugins/package-managers/spdx/src/main/kotlin/Spdx.kt, the
resolveDependencies method groups all SpdxPackage elements by
primaryPurpose and creates a flat PackageReference for each one:

// Spdx.kt:110–125
packagesByScope.forEach { (scopeName, packages) ->
    val packageRefs = packages.mapNotNullTo(mutableSetOf()) { spdxPkg ->
        val ortPackage = spdxPkg.toOrtPackage(licenseMap) ?: return@mapNotNullTo null
        ortPackages.merge(...)
        PackageReference(ortPackage.id)   // ← no `dependencies` populated
    }
    if (packageRefs.isNotEmpty()) {
        scopes += Scope(name = scopeName, dependencies = packageRefs)
    }
}

All relationships are retrieved at line 94 but the only method that consumes
them, buildLicenseMap() (line 161), filters exclusively for
HAS_DECLARED_LICENSE and HAS_CONCLUDED_LICENSE. The dependsOn and
contains relationship types are silently ignored for the purpose of
dependency graph construction.


Suggested fix

Add a dependency-tree building step after packages are collected:

  1. Find the software_Sbom element's contains relationships to identify the
    direct (scope-root) package set.
  2. Build an adjacency map from dependsOn relationships:
    Map<String, Set<String>> keyed by from element URI.
  3. Recursively construct PackageReference nodes with populated dependencies
    starting from the direct set, using a visited set to handle shared
    dependencies (a package can be depended on by multiple parents).
  4. Use only the direct set as scope root entries rather than all packages.

This is the same recursive PackageReference-tree pattern used by most other
ORT package manager plugins (e.g. Maven, Npm).


Additional context

Metadata

Metadata

Assignees

Labels

analyzerAbout the analyzer tool

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions