Skip to content

license: derive a license from the free-form License field - #40

Merged
jonyoder merged 2 commits into
mainfrom
feat/freeform-license-detection
Aug 19, 2026
Merged

license: derive a license from the free-form License field#40
jonyoder merged 2 commits into
mainfrom
feat/freeform-license-detection

Conversation

@jonyoder

Copy link
Copy Markdown
Collaborator

Derives a license from the legacy free-form License field when neither License-Expression nor a License :: classifier says anything. That population previously derived to Unknown.

This is PR 1 of the chain for rstudio/package-manager#19138. The producer call in rstudio/pypi-manifest and the PPM adapter follow separately; nothing here is customer-visible until the corpus is re-encoded and republished.

The design property that matters

One unrecognized token rejects the whole field.

The field resolves only when it is itself a published, non-deprecated SPDX identifier, or an SPDX expression whose every identifier is one. That single rule is what makes it impossible to invent a license here. ParseSPDXExpression does not validate, it strips operators and hands back whatever tokens remain, so 3-Clause BSD License tokenizes to 3-Clause / BSD / License. Accepting the recognizable-looking part of that would attribute BSD-3-Clause to a package on the strength of a word.

The rule also settles the ambiguous spellings without anyone adjudicating them:

  • bare GPL and GPLv3 are not identifiers (the identifiers are GPL-3.0-only, GPL-3.0-or-later), so they are rejected rather than resolved to a guessed version
  • bare BSD names a family with several incompatible members
  • aliases in real use, Apache 2.0 and MIT License, are rejected for the same reason. Recognizing them is a separate decision requiring per-entry human review, and the strict gate defers that tier automatically

Matching is case-insensitive, so mit resolves to MIT. An exception is recognized but never returned, since the identifiers here are licenses: Apache-2.0 WITH LLVM-exception yields Apache-2.0, while MIT with restrictions is rejected, because reporting plain MIT for it would state the opposite of what the package said.

Because the tier runs last, it can only move a package out of Unknown. It can never overrule a license the package declared through a structured field.

Measured against real data, not invented strings

Scored against the live PyPI corpus captured 2026-08-18: 1,745,000 free-form-only (name, version) pairs over 17,846 distinct strings.

48.35% resolve (843,695 versions) from 278 distinct strings.

Largest accepted: MIT 600,772 · Apache-2.0 167,432 · BSD-3-Clause 18,546 · mit 6,922 · GPL-3.0-or-later 6,845.

Largest still rejected, all correctly: UNKNOWN 69,824 · Apache 2.0 63,176 · Apache License 2.0 57,107 · BSD 44,585 · MIT License 40,663 · GPL 31,081 · GPLv3 23,697.

The 250 highest-volume strings are committed as license/testdata/freeform_corpus.json, a regression fixture covering 87.9% of the corpus by volume. It is explicitly not a specification: every non-Unknown row is a claim about a real package's license, so a row that moves needs a human to decide whether the new answer is right, not a re-record.

SPDX identifiers now come from the published list

license/data/spdx_licenses.json, generated by license/data/gen_spdx_licenses.py, replaces an ad hoc set. Deprecated identifiers are excluded, so GPL-3.0 (9,769 versions) and AGPL-3.0 (8,223) do not resolve, being ambiguous between the -only and -or-later forms that replaced them.

The Unknown sentinel is barred from the lookup table in two independent places, the generator and buildSPDXIDDB, so a free-form field spelling UNKNOWN can never be laundered into a type indistinguishable from a real derivation.

Breaking

Types takes a third argument, the raw free-form License field. Pass "" to keep the previous behaviour exactly. The only consumer is PPM.

Verification

  • go test -race ./... green across every package
  • golangci-lint run ./... 0 issues, gofmt clean
  • explicit negative tests for every string the gate must refuse, each annotated with its corpus volume
  • precedence tests pinning that expression and classifiers both beat the free-form field

Types now consults the legacy free-form License field when neither
License-Expression nor a License:: classifier says anything. That
population previously derived to Unknown.

The tier is strict by design: one unrecognized token rejects the whole
field. It resolves only when the field is itself a published,
non-deprecated SPDX identifier, or an expression whose every identifier
is one. That is what makes it impossible to invent a license here --
"3-Clause BSD License" tokenizes to 3-Clause / BSD / License, and
accepting the plausible part would attribute BSD-3-Clause to a package
on the strength of a word. The same rule rejects bare GPL, GPLv3 and
BSD, and defers the alias tier (Apache 2.0, MIT License) without anyone
having to adjudicate it.

Because the tier runs last it can only move a package out of Unknown,
never overrule a license declared through a structured field.

Identifiers are now checked against the published SPDX License List
rather than an ad hoc set, with deprecated ids excluded.

Measured against the live PyPI corpus of 1,745,000 free-form-only
versions captured 2026-08-18: 48.35% resolve. The 250 highest-volume
strings are committed as a regression fixture.

Types takes a third argument and is a breaking change for callers.
Review found a wrong-license attribution reachable from real PyPI
metadata: a package whose only structured declaration was
"License :: Other/Proprietary License" derived to MIT if its free-form
License field said MIT.

StandardizeLicensePyPIClassifiers collapses the 17 classifiers SPDX has
no identifier for to Unknown, so an unmappable classifier arrived at the
free-form tier looking identical to a package that declared nothing. But
such a classifier still says something, and "proprietary" in particular
says the opposite of what the free-form string claimed.

Suppress the tier on the presence of any License:: classifier rather
than on the value it mapped to. The bare "License :: OSI Approved"
umbrella still does not count, matching how the classifier tier already
treats it.

This costs nothing on the measured population: the corpus selects
versions whose only license signal is the free-form field, so it
contains no License:: classifiers at all, and the accept rate is
unchanged at 48.35%.

Also correct two overclaims the review flagged. The strict token rule
stops an UNRECOGNIZED token from producing a license; it does not
guarantee a recognized token was meant as an identifier, since SPDX
publishes ~120 single-word ids including company names. Measured, that
residual is 5 of 843,695 resolving versions. And the deprecated-id check
is scoped to the free-form tier; License-Expression still passes through
unvalidated.

Rekey the corpus fixture on what RESOLVES rather than what is frequent.
All 278 resolving strings are now covered, plus the 60 highest-volume
rejections as anchors. Sampling by volume omitted the entire tail, which
is exactly where the single-word risk lives.
@jonyoder
jonyoder merged commit 9c866d5 into main Aug 19, 2026
4 checks passed
@jonyoder
jonyoder deleted the feat/freeform-license-detection branch August 19, 2026 12:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant