Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions .github/workflows/update-licenses.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,9 @@ jobs:

echo "Downloaded $count licenses"

# Filter to OSS licenses and only needed fields
jq '[.[] | select(.category == "Permissive" or .category == "Copyleft" or .category == "Copyleft Limited" or .category == "Public Domain" or .category == "Free Restricted" or .category == "Source-available") | {license_key, category, spdx_license_key, other_spdx_license_keys, is_exception, is_deprecated}]' licenses.json.full > licenses.json.new
jq '[.[] | {license_key, category, spdx_license_key, other_spdx_license_keys, is_exception, is_deprecated}]' licenses.json.full > licenses.json.new
rm licenses.json.full

filtered_count=$(jq length licenses.json.new)
echo "Filtered to $filtered_count OSS licenses"

- name: Check for changes
id: diff
run: |
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ Available categories:
- `CategoryCopyleftLimited` - LGPL-*, MPL-*, EPL-*
- `CategoryPublicDomain` - Unlicense, CC0-1.0
- `CategoryCommercial` - Commercial licenses
- `CategoryNonCommercial` - Non-commercial use only
- `CategoryProprietaryFree` - Free but proprietary
- `CategorySourceAvailable` - Source-available licenses
- `CategoryPatentLicense` - Patent grants
Expand Down
110 changes: 50 additions & 60 deletions category.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const (
CategoryCopyleft Category = "Copyleft"
CategoryCopyleftLimited Category = "Copyleft Limited"
CategoryCommercial Category = "Commercial"
CategoryNonCommercial Category = "Non-Commercial"
CategoryProprietaryFree Category = "Proprietary Free"
CategoryPublicDomain Category = "Public Domain"
CategoryPatentLicense Category = "Patent License"
Expand All @@ -40,45 +41,55 @@ type licenseEntry struct {

var (
categoryOnce sync.Once
categoryMap map[string]Category // lowercase SPDX key -> category
licenseIndex map[string]*licenseEntry // lowercase key -> entry
licenseData []licenseEntry
)

func initCategoryMap() {
categoryOnce.Do(func() {
if err := json.Unmarshal(licensesJSON, &licenseData); err != nil {
// If JSON is invalid, map will be empty
categoryMap = make(map[string]Category)
licenseIndex = make(map[string]*licenseEntry)
return
}

// Each entry may have a primary SPDX key, alternative keys, and a license_key.
// Keys are added in priority order so that an SPDX identifier is
// never shadowed by another entry's alternate or ScanCode key, such
// as BSL-1.0 (Boost) and ScanCode's bsl-1.0 (Business Source).
estimatedKeys := 3
categoryMap = make(map[string]Category, len(licenseData)*estimatedKeys)
for _, entry := range licenseData {
cat := Category(entry.Category)
if cat == "" {
cat = CategoryUnknown
licenseIndex = make(map[string]*licenseEntry, len(licenseData)*estimatedKeys)
add := func(key string, entry *licenseEntry) {
key = strings.ToLower(key)
if _, exists := licenseIndex[key]; key != "" && !exists {
licenseIndex[key] = entry
}

// Map primary SPDX key
if entry.SPDXLicenseKey != "" {
categoryMap[strings.ToLower(entry.SPDXLicenseKey)] = cat
}

// Map alternative SPDX keys (skip LicenseRef- ones)
for _, key := range entry.OtherSPDXKeys {
if !strings.HasPrefix(key, "LicenseRef-") {
categoryMap[strings.ToLower(key)] = cat
}
}
for i := range licenseData {
add(licenseData[i].SPDXLicenseKey, &licenseData[i])
}
for i := range licenseData {
for _, key := range licenseData[i].OtherSPDXKeys {
add(key, &licenseData[i])
}

// Also map the license_key itself
categoryMap[strings.ToLower(entry.LicenseKey)] = cat
}
for i := range licenseData {
add(licenseData[i].LicenseKey, &licenseData[i])
}
})
}

func lookupLicenseEntry(license string) *licenseEntry {
initCategoryMap()
return licenseIndex[strings.ToLower(license)]
}

func (e *licenseEntry) category() Category {
if e.Category == "" {
return CategoryUnknown
}
return Category(e.Category)
}

// LicenseCategory returns the category for a given license identifier.
// It accepts SPDX identifiers (like "MIT", "Apache-2.0") or scancode keys.
// Returns CategoryUnknown if the license is not found.
Expand All @@ -89,18 +100,15 @@ func initCategoryMap() {
// LicenseCategory("GPL-3.0-only") // CategoryCopyleft
// LicenseCategory("MPL-2.0") // CategoryCopyleftLimited
func LicenseCategory(license string) Category {
initCategoryMap()

// Try exact match first
if cat, ok := categoryMap[strings.ToLower(license)]; ok {
return cat
if entry := lookupLicenseEntry(license); entry != nil {
return entry.category()
}

// Try without -only/-or-later suffixes
license = strings.TrimSuffix(license, "-only")
license = strings.TrimSuffix(license, "-or-later")
if cat, ok := categoryMap[strings.ToLower(license)]; ok {
return cat
if entry := lookupLicenseEntry(license); entry != nil {
return entry.category()
}

return CategoryUnknown
Expand All @@ -117,7 +125,7 @@ func LicenseCategory(license string) Category {
// ExpressionCategories("MIT OR GPL-3.0-only")
// // []Category{CategoryPermissive, CategoryCopyleft}
func ExpressionCategories(expression string) ([]Category, error) {
licenses, err := ExtractLicenses(expression)
licenses, err := licenseIDs(expression)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -165,7 +173,7 @@ func IsCommercial(license string) bool {
// HasCopyleft("MIT OR GPL-3.0-only") // true
// HasCopyleft("MIT AND LGPL-2.1-only") // true
func HasCopyleft(expression string) bool {
licenses, err := ExtractLicenses(expression)
licenses, err := licenseIDs(expression)
if err != nil {
return false
}
Expand All @@ -187,7 +195,7 @@ func HasCopyleft(expression string) bool {
// IsFullyPermissive("MIT AND BSD-3-Clause") // true
// IsFullyPermissive("MIT OR GPL-3.0-only") // false
func IsFullyPermissive(expression string) bool {
licenses, err := ExtractLicenses(expression)
licenses, err := licenseIDs(expression)
if err != nil {
return false
}
Expand All @@ -212,33 +220,15 @@ type LicenseInfo struct {
// GetLicenseInfo returns detailed information about a license.
// Returns nil if the license is not found.
func GetLicenseInfo(license string) *LicenseInfo {
initCategoryMap()

lower := strings.ToLower(license)

for _, entry := range licenseData {
// Check SPDX key
if strings.ToLower(entry.SPDXLicenseKey) == lower {
return &LicenseInfo{
Key: entry.LicenseKey,
SPDXKey: entry.SPDXLicenseKey,
Category: Category(entry.Category),
IsException: entry.IsException,
IsDeprecated: entry.IsDeprecated,
}
}

// Check license key
if strings.ToLower(entry.LicenseKey) == lower {
return &LicenseInfo{
Key: entry.LicenseKey,
SPDXKey: entry.SPDXLicenseKey,
Category: Category(entry.Category),
IsException: entry.IsException,
IsDeprecated: entry.IsDeprecated,
}
}
entry := lookupLicenseEntry(license)
if entry == nil {
return nil
}
return &LicenseInfo{
Key: entry.LicenseKey,
SPDXKey: entry.SPDXLicenseKey,
Category: Category(entry.Category),
IsException: entry.IsException,
IsDeprecated: entry.IsDeprecated,
}

return nil
}
30 changes: 30 additions & 0 deletions category_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ func TestLicenseCategory(t *testing.T) {
// Public Domain
"Unlicense": CategoryPublicDomain,
"CC0-1.0": CategoryPublicDomain,

// SPDX identifiers take precedence over ScanCode keys
"BSL-1.0": CategoryPermissive,
"bsl-1.0": CategoryPermissive,
"LicenseRef-scancode-bsl-1.0": CategorySourceAvailable,

"LicenseRef-scancode-3dslicer-1.0": CategoryPermissive,
"LicenseRef-scancode-acki-nacki-node-2024-10-04": CategoryCommercial,
"LicenseRef-scancode-acm-sla": CategoryNonCommercial,
}

for license, expected := range tests {
Expand Down Expand Up @@ -135,6 +144,20 @@ func TestGetLicenseInfo(t *testing.T) {
if !info.IsException {
t.Error("GetLicenseInfo(\"Classpath-exception-2.0\").IsException = false, want true")
}

info = GetLicenseInfo("LicenseRef-scancode-3dslicer-1.0")
if info == nil || info.SPDXKey != "3D-Slicer-1.0" {
t.Errorf("GetLicenseInfo(alternate key) = %+v, want SPDXKey 3D-Slicer-1.0", info)
}

info = GetLicenseInfo("BSL-1.0")
if info == nil || info.Key != "boost-1.0" {
t.Errorf("GetLicenseInfo(\"BSL-1.0\") = %+v, want Key boost-1.0", info)
}

if !IsCommercial("LicenseRef-scancode-acki-nacki-node-2024-10-04") {
t.Error("IsCommercial(commercial license) = false, want true")
}
}

func TestHasCopyleft(t *testing.T) {
Expand All @@ -147,6 +170,9 @@ func TestHasCopyleft(t *testing.T) {
"MIT AND LGPL-2.1-only": true,
"Apache-2.0 OR MPL-2.0": true, // MPL is weak copyleft
"Unlicense OR CC0-1.0": false, // public domain

"GPL-2.0-only WITH Classpath-exception-2.0": true,
"MIT AND (GPL-2.0+ WITH LicenseRef-scancode-bacula-exception)": true,
}

for expr, expected := range tests {
Expand All @@ -170,6 +196,10 @@ func TestIsFullyPermissive(t *testing.T) {
"MIT OR GPL-3.0-only": false,
"MIT AND LGPL-2.1-only": false,
"Apache-2.0 OR MPL-2.0": false, // MPL is copyleft limited

"MIT AND (Apache-2.0 WITH LLVM-exception)": true,
"Apache-2.0 WITH LicenseRef-scancode-generic-exception": true,
"GPL-2.0-only WITH Classpath-exception-2.0": false,
}

for expr, expected := range tests {
Expand Down
Loading
Loading