Skip to content

Evaluate SPDX license expressions instead of plain identifiers - #311

Merged
dennisdoomen merged 2 commits into
mainfrom
claude/217-spdx-expressions
Oct 25, 2026
Merged

dennisdoomen merged 2 commits into
mainfrom
claude/217-spdx-expressions

Conversation

@dennisdoomen

@dennisdoomen dennisdoomen commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

Closes #217

Problem

Licenses were compared as whole strings. A package that declares MIT OR Apache-2.0 failed an allow list that has both MIT and Apache-2.0.

What changed

A new internal LicenseExpression class parses SPDX expressions: OR, AND, WITH, parentheses and the + suffix. Operators are not case-sensitive. Text that is not a valid expression, such as Microsoft .NET Library License or Unknown, is still matched as a whole string, as before.

Expression Allowed when Denied or warned about when
A OR B one option is allowed every option is denied
A AND B every license is allowed one license is denied
A WITH E A WITH E or A is allowed only A WITH E is denied
A+, A-or-later the license or its base version is allowed the license itself is denied

Other changes:

  • Configuration entries can be expressions too. For example, "GPL-2.0-only WITH Classpath-exception-2.0".
  • Deprecated GNU identifiers are normalised. GPL-3.0 becomes GPL-3.0-only and GPL-2.0+ becomes GPL-2.0-or-later. This applies to package licenses and to config entries.
  • The reason names the license that matched. For example: matches allow list license entry "MIT" (from "MIT OR Apache-2.0").
  • LegalRiskEvaluator uses the parsed expression. Before, it split the license on " OR ". Now it scores the least restrictive option of an OR and the most restrictive license of an AND.
  • LicenseClassifier, used by init, follows the same rules. It now also recognises deprecated identifiers.
  • One place for the license check. CSharpProjectAnalysisStrategy.UpdateLicensePolicyCompatibility had its own copy of the license check. It now uses the same matching.

Why no library

The issue suggested a library. NuGet.Packaging's NuGetLicenseExpression.Parse is already in the graph as a transitive dependency, so I tried it on real license values. It accepts unknown ids, but it throws on several values that our fetchers and npm packages produce:

Input NuGet parser
GPL-3.0, GPL-2.0+ throws: "The identifier is deprecated"
MIT or Apache-2.0 throws: "Invalid element 'or'"
GPL-2.0-only WITH Unknown-exception throws: "not a standard exception"
Microsoft .NET Library License, MIT OR throws
Some-Unknown-Id, mit, Apache-2.0+ parses

So we would still have to rewrite tokens before parsing and keep a fallback for free text. A small recursive descent parser (about 60 lines) does all of that in one place.

Also fixed

LegalRiskEvaluator scored every LGPL-* license as restrictive (6.0), because the substring check for "GPL" ran before the weak copyleft check. It now checks weak copyleft first, so LGPL scores 3.0.

Docs

configuration.md (new "License expressions" table), risk-metrics.md, intro.md and README.md.

Tests

LicenseExpressionSpecs covers:

  • OR, AND and parentheses, for allow and for deny
  • WITH for allow and for deny
  • + and -or-later
  • deprecated identifiers
  • free text and incomplete expressions
  • the reason text
  • warn lists
  • legal risk for AND
  • the classifier
  • LGPL scored as weak copyleft (RiskEvaluatorSpecs)

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Test Results

    3 files  ± 0      3 suites  ±0   6m 10s ⏱️ -10s
  534 tests +29    534 ✅ +29  0 💤 ±0  0 ❌ ±0 
1 067 runs  +58  1 067 ✅ +58  0 💤 ±0  0 ❌ ±0 

Results for commit 6b22540. ± Comparison against base commit f7e8593.

♻️ This comment has been updated with latest results.

Comment thread Src/PackageGuard.Core/Policy/LicenseExpression.cs Outdated
Comment thread Src/PackageGuard.Core/Policy/LicenseExpression.cs Outdated
Comment thread Src/PackageGuard.Specs/LicenseExpressionSpecs.cs Outdated
Comment thread Src/PackageGuard.Core/Policy/LicenseExpression.cs Outdated
Comment thread Src/PackageGuard.Specs/LicenseExpressionSpecs.cs Outdated
Comment thread Src/PackageGuard.Core/Scaffolding/LicenseClassifier.cs Outdated
Comment thread Src/PackageGuard.Core/Policy/WarnList.cs Outdated
Comment thread Src/PackageGuard.Core/Policy/LicenseExpression.cs Outdated
Comment thread website/docs/configuration.md Outdated
/// Folds this expression into a single value, using <paramref name="term"/> for every single license and
/// <paramref name="or"/> and <paramref name="and"/> to combine the operands of compound expressions.
/// </summary>
public T Evaluate<T>(Func<LicenseExpression, T> term, Func<T, T, T> or, Func<T, T, T> and) => Operator switch

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💪🤖 Nice: one Evaluate fold drives allow, deny, warn, legal risk and the classifier.

@dennisdoomen

Copy link
Copy Markdown
Owner Author

🏕️🤖 Only CSharpProjectAnalysisStrategy.UpdateLicensePolicyCompatibility (Src/PackageGuard.Core/CSharp/CSharpProjectAnalysisStrategy.cs:158) sets IsLicensePolicyCompatible, so npm packages never get the policy-mismatch legal risk.

dennisdoomen and others added 2 commits October 25, 2026 17:26
Licenses were compared as whole strings, so a package with "MIT OR
Apache-2.0" failed an allow list that has both MIT and Apache-2.0.

A small parser now reads OR, AND, WITH, parentheses and the "+" suffix,
and replaces deprecated GNU identifiers such as GPL-3.0 with their
current form. Text that is not a valid expression is matched as is.

- Allow: OR needs one allowed option, AND needs all. A WITH term is
  allowed by the full term or by its base license.
- Deny and warn: OR needs every option denied, AND needs one. A WITH
  term is only denied by the full term.
- The reason names the license that matched, for example
  'matches allow list license entry "MIT" (from "MIT OR Apache-2.0")'.
- Legal risk and the init classifier use the least restrictive option of
  an OR and the most restrictive license of an AND.

Closes #217

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The legal risk evaluator checked for restrictive licenses first, using a
substring match on "GPL". Because "LGPL" contains "GPL", every LGPL
license got the restrictive score of 6.0 instead of 3.0.

Also mention license expressions in the risk metrics page, the intro and
the README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 40211711527

Coverage decreased (-0.4%) to 80.772%

Details

  • Coverage decreased (-0.4%) from the base build.
  • Patch coverage: 168 of 168 lines across 8 files are fully covered (100%).
  • 50 coverage regressions across 4 files.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

50 previously-covered lines in 4 files lost coverage.

File Lines Losing Coverage Coverage
Src/PackageGuard.Core/Risk/Enrichment/GitHubRepositoryRiskEnricher.cs 35 80.7%
Src/PackageGuard.Core/Risk/Scoring/IssueHealthRiskFactor.cs 10 88.13%
Src/PackageGuard.Core/Risk/Scoring/SecurityRiskEvaluator.cs 3 95.59%
Src/PackageGuard.Core/GitHub/GitHubResponseFields.cs 2 93.55%

Coverage Stats

Coverage Status
Relevant Lines: 9946
Covered Lines: 8295
Line Coverage: 83.4%
Relevant Branches: 3623
Covered Branches: 2665
Branch Coverage: 73.56%
Branches in Coverage %: Yes
Coverage Strength: 2136.64 hits per line

💛 - Coveralls

@dennisdoomen dennisdoomen added the enhancement New feature or request label Oct 25, 2026
@dennisdoomen
dennisdoomen merged commit 3e8a398 into main Oct 25, 2026
12 of 13 checks passed
@dennisdoomen
dennisdoomen deleted the claude/217-spdx-expressions branch October 25, 2026 17:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Support SPDX license expressions (OR, AND, WITH) instead of plain identifiers

2 participants