Skip to content

Detect license changes in diff (parsed Component.license is currently extracted but never compared) #9

Description

@dmchaledev

Summary

The parser already extracts a license for every component, stores it on Component.license, and then diff() silently discards it. For a package whose keywords include supply-chain-security and vulnerability-management, a dependency quietly changing its license (e.g. MIT → AGPL‑3.0, or any permissive → copyleft transition) is a material legal/compliance risk — and the tool already has all the data needed to surface it, but doesn't.

This proposes adding license-change detection to the diff output. It's a natural, low-risk extension of the existing model that turns already-parsed-but-unused data into a real signal.

Evidence (current state)

The data is parsed on both code paths but never used downstream:

  • src/parser.ts — CycloneDX: extractCycloneDXLicense(c) populates Component.license (parser.ts:33, parser.ts:106).
  • src/parser.ts — SPDX: licenseConcluded populates Component.license (parser.ts:66).
  • src/types.tsComponent.license?: string is defined (types.ts:19) but ChangeReport has no license field (types.ts:70).
  • src/diff.tsdiff() compares version for upgrades but never reads .license (diff.ts:23).
  • src/reporter.ts — no license column/section in any of text / json / markdown.

So a component that stays at the same version but changes its declared license produces zero output today.

Proposed change

When a component exists in both SBOMs (matched by the same key diff() already uses) and its license differs, record it as a license change.

1. Types (src/types.ts)

export interface LicenseChange {
  component: Component;
  from?: string;   // license in the old SBOM (undefined if previously unset)
  to?: string;     // license in the new SBOM
}

export interface ChangeReport {
  // ...existing fields...
  licenseChanges: LicenseChange[];
  summary: {
    // ...existing counts...
    totalLicenseChanges: number;
  };
}

2. Diff (src/diff.ts)

In the existing matched-component branch (the same place upgrades are detected), add:

if ((aComp.license ?? '') !== (bComp.license ?? '')) {
  licenseChanges.push({ component: bComp, from: aComp.license, to: bComp.license });
}

This reuses the same component map / matching logic, so it composes with whatever key strategy diff() settles on (note PR #8 is reworking the purl key — this slots in cleanly either way).

3. Reporter (src/reporter.ts)

Add a "License Changes" section to text and markdown (mirroring the existing Upgraded section). json is automatic since it serializes the full report. Example markdown:

## ⚖️ License Changes

| Component | From | To |
|-----------|------|----|
| left-pad  | MIT  | AGPL-3.0 |

4. Tests (src/__tests__/diff.test.ts)

  • same version, license MIT → AGPL-3.0 ⇒ one licenseChanges entry
  • license unchanged ⇒ none
  • license newly added (undefined → MIT) ⇒ one entry (or document that this is intentionally reported)

Why this is high-leverage

  • Aligned with the stated purpose: the README targets "security engineers, DevSecOps teams, and supply-chain risk analysts … for compliance evidence." License drift is squarely in that remit.
  • Zero new parsing / dependencies: the field is already extracted on both formats; this only wires existing data into the diff + report.
  • Composable with the CI gate (PR feat(cli): add --fail-on CI/CD gate, --help/--version, robust arg parsing #5): a future --fail-on license-change becomes trivial once licenseChanges exists in the report.
  • Backward compatible: purely additive to ChangeReport; no change to existing fields or default CLI behavior.

Happy to open a focused PR for this once the in-flight diff/CLI PRs (#5#8) land, to avoid conflicts in diff.ts / reporter.ts.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions