From c8ef2425173a47a81135455b5ee415f7c7731cab Mon Sep 17 00:00:00 2001 From: "E. Lynette Rayle" Date: Mon, 26 Jan 2026 09:54:03 -0500 Subject: [PATCH 1/6] benchmark tests for validating MIT license MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two benchmarks were added: 1) `spdxexp.ValidateLicenses([]string{"MIT”})` 2) `”MIT” == “MIT”` NOTE: The array `[]string{"MIT”})` is created outside the benchmark loop to avoid it contributing to the benchmark time. --- cmd/benchmark_test.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 cmd/benchmark_test.go diff --git a/cmd/benchmark_test.go b/cmd/benchmark_test.go new file mode 100644 index 0000000..240d9cd --- /dev/null +++ b/cmd/benchmark_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "testing" + + "github.com/github/go-spdx/v2/spdxexp" +) +func BenchmarkValidateLicensesMIT(b *testing.B) { + b.ReportAllocs() + + licenses := []string{"MIT"} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + valid, invalid := spdxexp.ValidateLicenses(licenses) + if !valid || len(invalid) != 0 { + b.Fatalf("expected MIT to be valid; valid=%v invalid=%v", valid, invalid) + } + } +} + +func BenchmarkStringEqualityMIT(b *testing.B) { + b.ReportAllocs() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if "MIT" != "MIT" { + b.Fatal("unexpected string inequality") + } + } +} From 38dff46dd1c9845d07e72a72a368cfaa21d3e4d5 Mon Sep 17 00:00:00 2001 From: "E. Lynette Rayle" Date: Mon, 26 Jan 2026 15:35:22 -0500 Subject: [PATCH 2/6] add summary table for quick evaluation of both methods The benchmark and summary will allow us to quantify changes to the algorithm to help us work to a more efficient and scalable evaluation process. --- cmd/benchmark_test.go | 138 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/cmd/benchmark_test.go b/cmd/benchmark_test.go index 240d9cd..3a7b473 100644 --- a/cmd/benchmark_test.go +++ b/cmd/benchmark_test.go @@ -1,10 +1,148 @@ package main import ( + "fmt" + "math" + "os" + "strings" "testing" "github.com/github/go-spdx/v2/spdxexp" ) + +func TestMain(m *testing.M) { + fmt.Fprintln(os.Stdout, "Benchmark output columns (Go 'go test -bench'):") + fmt.Fprintln(os.Stdout, "- BenchmarkName-: which benchmark ran and with how many OS threads") + fmt.Fprintln(os.Stdout, "- iters: number of iterations (b.N) executed") + fmt.Fprintln(os.Stdout, "- ns/op: average time per iteration") + fmt.Fprintln(os.Stdout, "- B/op: bytes allocated per iteration (shown with -benchmem)") + fmt.Fprintln(os.Stdout, "- allocs/op: allocations per iteration (shown with -benchmem)") + fmt.Fprintln(os.Stdout, "") + + code := m.Run() + + // Compute an observed relative scale factor using the benchmark functions. + // This is separate from the `go test -bench ...` results (which are printed + // above), but it gives a concrete, machine-specific ratio to show at a glance. + eq := testing.Benchmark(BenchmarkStringEqualityMIT) + val := testing.Benchmark(BenchmarkValidateLicensesMIT) + + // Prefer a floating-point ns/op average for display so sub-nanosecond results + // don't get rounded to 0. + eqNsAvg := 0.0 + valNsAvg := 0.0 + if eq.N > 0 { + eqNsAvg = float64(eq.T.Nanoseconds()) / float64(eq.N) + } + if val.N > 0 { + valNsAvg = float64(val.T.Nanoseconds()) / float64(val.N) + } + formatNsAvg := func(ns float64) string { + if ns < 10 { + return fmt.Sprintf("~%.1f ns/op", ns) + } + rounded := int64(math.Round(ns)) + return fmt.Sprintf("~%s ns/op", formatWithCommas(rounded)) + } + formatScale := func(val, baseline float64) string { + if baseline <= 0 { + return "n/a" + } + ratio := val / baseline + if ratio < 1 { + ratio = 1 + } + + // Round to 2 significant digits to match the practical precision of these + // measurements (e.g. 9597 -> 9600, 843 -> 840). + rounded := ratio + if ratio >= 10 { + magnitude := math.Pow(10, math.Floor(math.Log10(ratio))-1) // keep 2 sig digits + rounded = math.Round(ratio/magnitude) * magnitude + } else { + // For very small ratios, keep a single decimal place. + rounded = math.Round(ratio*10) / 10 + } + + if rounded >= 10 { + return fmt.Sprintf("~%sx", formatWithCommas(int64(rounded))) + } + if rounded == math.Trunc(rounded) { + return fmt.Sprintf("~%dx", int64(rounded)) + } + return fmt.Sprintf("~%.1fx", rounded) + } + nsOpEq := formatNsAvg(eqNsAvg) + nsOpVal := formatNsAvg(valNsAvg) + scaleVal := formatScale(valNsAvg, eqNsAvg) + + fmt.Fprintln(os.Stdout, "\nScalability summary (at a glance)") + + col1 := 28 + col2 := 14 + col3 := 44 + + line := func() { + fmt.Fprintf(os.Stdout, "+-%s-+-%s-+-%s-+\n", strings.Repeat("-", col1), strings.Repeat("-", col2), strings.Repeat("-", col3)) + } + row := func(c1, c2, c3 string) { + fmt.Fprintf(os.Stdout, "| %-*s | %-*s | %-*s |\n", col1, c1, col2, c2, col3, c3) + } + + line() + row("Characteristic", "MIT==MIT", "ValidateLicenses([\"MIT\"]) ") + line() + row("ns/op average", nsOpEq, nsOpVal) + row("Scale", "1x", scaleVal) + row("Time per check", "O(1)", "~O(M*L) (parse each of M licenses)") + row("Memory per check", "O(1)", "~O(M) allocs (see B/op, allocs/op)") + line() + fmt.Fprintln(os.Stdout, "") + fmt.Fprintln(os.Stdout, "Measurement tip: for strict comparisons, keep ops/run equal (-benchtime=1000x) and increase repeats (-count=10+) then compare with benchstat.") + + os.Exit(code) +} + +func formatWithCommas(n int64) string { + s := fmt.Sprintf("%d", n) + if len(s) <= 3 { + return s + } + + var b strings.Builder + pre := len(s) % 3 + if pre == 0 { + pre = 3 + } + b.WriteString(s[:pre]) + for i := pre; i < len(s); i += 3 { + b.WriteByte(',') + b.WriteString(s[i : i+3]) + } + return b.String() +} + +// Benchmark summary (scalability-focused) +// +// BenchmarkStringEqualityMIT measures a constant-time operation: comparing two +// already-in-memory short string literals. This is O(1) time, ~0 allocations, +// and scales linearly only with how many comparisons you do. +// +// BenchmarkValidateLicensesMIT measures SPDX license validation via parsing. +// Even for a single license, this is substantially heavier because it creates +// parser structures and does work proportional to the license string length. +// +// Scalability implications: +// - If you validate M licenses, ValidateLicenses is ~O(M) calls to parse(), so +// total cost grows roughly linearly with M (and with average string length). +// - If license strings are expressions, runtime also scales with expression +// complexity (more tokens/nodes) and may allocate more. +// - The string equality baseline stays near O(1) per comparison with minimal +// memory traffic. +// +// In practice, for “at scale” validation (large M, long expressions, repeated +// checks), the dominant lever is avoiding repeated parsing (e.g., parse once and +// reuse/caching parsed nodes) rather than micro-optimizing string comparisons. func BenchmarkValidateLicensesMIT(b *testing.B) { b.ReportAllocs() From 0cb8f2f253329fa426fea0a65e3e0443d0b86b29 Mon Sep 17 00:00:00 2001 From: "E. Lynette Rayle" Date: Mon, 26 Jan 2026 15:51:36 -0500 Subject: [PATCH 3/6] fix linter errors --- cmd/benchmark_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cmd/benchmark_test.go b/cmd/benchmark_test.go index 3a7b473..8340387 100644 --- a/cmd/benchmark_test.go +++ b/cmd/benchmark_test.go @@ -55,7 +55,7 @@ func TestMain(m *testing.M) { // Round to 2 significant digits to match the practical precision of these // measurements (e.g. 9597 -> 9600, 843 -> 840). - rounded := ratio + rounded := 0.0 if ratio >= 10 { magnitude := math.Pow(10, math.Floor(math.Log10(ratio))-1) // keep 2 sig digits rounded = math.Round(ratio/magnitude) * magnitude @@ -160,9 +160,12 @@ func BenchmarkValidateLicensesMIT(b *testing.B) { func BenchmarkStringEqualityMIT(b *testing.B) { b.ReportAllocs() + v1 := "MIT" + v2 := "MIT" + b.ResetTimer() for i := 0; i < b.N; i++ { - if "MIT" != "MIT" { + if v1 != v2 { b.Fatal("unexpected string inequality") } } From b343565d0956c1b29ce52530da90316da969a798 Mon Sep 17 00:00:00 2001 From: "E. Lynette Rayle" Date: Mon, 26 Jan 2026 17:49:49 -0500 Subject: [PATCH 4/6] Add benchmark to test performance of ActiveLicense check --- cmd/benchmark_test.go | 49 +++++++++++++++++++++++++++++++++---------- spdxexp/license.go | 5 +++++ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/cmd/benchmark_test.go b/cmd/benchmark_test.go index 8340387..222900b 100644 --- a/cmd/benchmark_test.go +++ b/cmd/benchmark_test.go @@ -25,15 +25,20 @@ func TestMain(m *testing.M) { // This is separate from the `go test -bench ...` results (which are printed // above), but it gives a concrete, machine-specific ratio to show at a glance. eq := testing.Benchmark(BenchmarkStringEqualityMIT) + act := testing.Benchmark(BenchmarkActiveLicenseMIT) val := testing.Benchmark(BenchmarkValidateLicensesMIT) // Prefer a floating-point ns/op average for display so sub-nanosecond results // don't get rounded to 0. eqNsAvg := 0.0 + actNsAvg := 0.0 valNsAvg := 0.0 if eq.N > 0 { eqNsAvg = float64(eq.T.Nanoseconds()) / float64(eq.N) } + if act.N > 0 { + actNsAvg = float64(act.T.Nanoseconds()) / float64(act.N) + } if val.N > 0 { valNsAvg = float64(val.T.Nanoseconds()) / float64(val.N) } @@ -73,29 +78,32 @@ func TestMain(m *testing.M) { return fmt.Sprintf("~%.1fx", rounded) } nsOpEq := formatNsAvg(eqNsAvg) + nsOpAct := formatNsAvg(actNsAvg) nsOpVal := formatNsAvg(valNsAvg) + scaleAct := formatScale(actNsAvg, eqNsAvg) scaleVal := formatScale(valNsAvg, eqNsAvg) fmt.Fprintln(os.Stdout, "\nScalability summary (at a glance)") - col1 := 28 - col2 := 14 - col3 := 44 + col1 := 22 + col2 := 12 + col3 := 20 + col4 := 28 line := func() { - fmt.Fprintf(os.Stdout, "+-%s-+-%s-+-%s-+\n", strings.Repeat("-", col1), strings.Repeat("-", col2), strings.Repeat("-", col3)) + fmt.Fprintf(os.Stdout, "+-%s-+-%s-+-%s-+-%s-+\n", strings.Repeat("-", col1), strings.Repeat("-", col2), strings.Repeat("-", col3), strings.Repeat("-", col4)) } - row := func(c1, c2, c3 string) { - fmt.Fprintf(os.Stdout, "| %-*s | %-*s | %-*s |\n", col1, c1, col2, c2, col3, c3) + row := func(c1, c2, c3, c4 string) { + fmt.Fprintf(os.Stdout, "| %-*s | %-*s | %-*s | %-*s |\n", col1, c1, col2, c2, col3, c3, col4, c4) } line() - row("Characteristic", "MIT==MIT", "ValidateLicenses([\"MIT\"]) ") + row("Characteristic", "MIT==MIT", "activeLicense(\"MIT\")", "ValidateLicenses([\"MIT\"])") line() - row("ns/op average", nsOpEq, nsOpVal) - row("Scale", "1x", scaleVal) - row("Time per check", "O(1)", "~O(M*L) (parse each of M licenses)") - row("Memory per check", "O(1)", "~O(M) allocs (see B/op, allocs/op)") + row("ns/op average", nsOpEq, nsOpAct, nsOpVal) + row("Scale", "1x", scaleAct, scaleVal) + row("Time per check", "O(1)", "~O(N*L)", "~O(M*L)") + row("Memory per check", "O(1)", "~O(N) bytes", "~O(M) allocs") line() fmt.Fprintln(os.Stdout, "") fmt.Fprintln(os.Stdout, "Measurement tip: for strict comparisons, keep ops/run equal (-benchtime=1000x) and increase repeats (-count=10+) then compare with benchstat.") @@ -128,6 +136,11 @@ func formatWithCommas(n int64) string { // already-in-memory short string literals. This is O(1) time, ~0 allocations, // and scales linearly only with how many comparisons you do. // +// BenchmarkActiveLicenseMIT measures checking whether a license ID exists in the +// SPDX active license list via a linear scan with a case-insensitive comparison. +// This is ~O(N*L) time (N = number of license IDs, L = average ID length). +// Note: the generated GetLicenses() currently allocates on each call; see B/op. +// // BenchmarkValidateLicensesMIT measures SPDX license validation via parsing. // Even for a single license, this is substantially heavier because it creates // parser structures and does work proportional to the license string length. @@ -157,6 +170,20 @@ func BenchmarkValidateLicensesMIT(b *testing.B) { } } +func BenchmarkActiveLicenseMIT(b *testing.B) { + b.ReportAllocs() + + id := "MIT" + + b.ResetTimer() + for i := 0; i < b.N; i++ { + ok, matched := spdxexp.ActiveLicense(id) + if !ok || matched != "MIT" { + b.Fatalf("expected MIT to be active; ok=%v matched=%q", ok, matched) + } + } +} + func BenchmarkStringEqualityMIT(b *testing.B) { b.ReportAllocs() diff --git a/spdxexp/license.go b/spdxexp/license.go index b47ef45..a1800b0 100644 --- a/spdxexp/license.go +++ b/spdxexp/license.go @@ -11,6 +11,11 @@ func activeLicense(id string) (bool, string) { return inLicenseList(spdxlicenses.GetLicenses(), id) } +// ActiveLicense returns true if the id is an active license. +func ActiveLicense(id string) (bool, string) { + return activeLicense(id) +} + // deprecatedLicense returns true if the id is a deprecated license. func deprecatedLicense(id string) (bool, string) { return inLicenseList(spdxlicenses.GetDeprecated(), id) From 586dda9a697b10de823a6b2a9efc26702ac2668b Mon Sep 17 00:00:00 2001 From: "E. Lynette Rayle" Date: Thu, 19 Feb 2026 12:43:07 -0500 Subject: [PATCH 5/6] add benchmarks specific to methods Allows for before and after comparisons for Satisfies and ValidateLicenses methods. BEFORE ``` +----------------------------+---------------+-------------------+ | Benchmark ValidateLicenses | ns/op average | Scale (5ns/op=1x) | +----------------------------+---------------+-------------------+ | MIT | ~4577.2 ns/op | ~915x | | mit | ~4772.5 ns/op | ~954x | | Apache-2.0 | ~3771.4 ns/op | ~754x | | Zed | ~5625.4 ns/op | ~1,125x | | MIT AND Apache-2.0 | ~8173.8 ns/op | ~1,635x | | MIT AND Apache-2.0 OR Zed | ~13,477 ns/op | ~2,695x | | GPL-2.0-or-later | ~4608.5 ns/op | ~922x | | GPL-2.0+ | ~8780.0 ns/op | ~1,756x | +----------------------------+---------------+-------------------+ +---------------------------+------------------+----------------------+ | Benchmark Satisfies | ns/op average | Scale (1500ns/op=1x) | +---------------------------+------------------+----------------------+ | MIT | ~4,213,130 ns/op | ~2,809x | | mit | ~4,263,859 ns/op | ~2,843x | | Apache-2.0 | ~2,598,757 ns/op | ~1,733x | | Zed | ~5,607,040 ns/op | ~3,738x | | MIT AND Apache-2.0 | ~4,481,596 ns/op | ~2,988x | | MIT AND Apache-2.0 OR Zed | ~4,482,058 ns/op | ~2,988x | | GPL-2.0-or-later | ~9,803,113 ns/op | ~6,535x | | GPL-2.0+ | ~9,703,581 ns/op | ~6,469x | +---------------------------+------------------+----------------------+ ``` --- cmd/benchmark_test.go | 199 -------- spdxexp/benchmark_satisfies_test.go | 493 ++++++++++++++++++++ spdxexp/benchmark_setup_test.go | 195 ++++++++ spdxexp/benchmark_validate_licenses_test.go | 65 +++ 4 files changed, 753 insertions(+), 199 deletions(-) delete mode 100644 cmd/benchmark_test.go create mode 100644 spdxexp/benchmark_satisfies_test.go create mode 100644 spdxexp/benchmark_setup_test.go create mode 100644 spdxexp/benchmark_validate_licenses_test.go diff --git a/cmd/benchmark_test.go b/cmd/benchmark_test.go deleted file mode 100644 index 222900b..0000000 --- a/cmd/benchmark_test.go +++ /dev/null @@ -1,199 +0,0 @@ -package main - -import ( - "fmt" - "math" - "os" - "strings" - "testing" - - "github.com/github/go-spdx/v2/spdxexp" -) - -func TestMain(m *testing.M) { - fmt.Fprintln(os.Stdout, "Benchmark output columns (Go 'go test -bench'):") - fmt.Fprintln(os.Stdout, "- BenchmarkName-: which benchmark ran and with how many OS threads") - fmt.Fprintln(os.Stdout, "- iters: number of iterations (b.N) executed") - fmt.Fprintln(os.Stdout, "- ns/op: average time per iteration") - fmt.Fprintln(os.Stdout, "- B/op: bytes allocated per iteration (shown with -benchmem)") - fmt.Fprintln(os.Stdout, "- allocs/op: allocations per iteration (shown with -benchmem)") - fmt.Fprintln(os.Stdout, "") - - code := m.Run() - - // Compute an observed relative scale factor using the benchmark functions. - // This is separate from the `go test -bench ...` results (which are printed - // above), but it gives a concrete, machine-specific ratio to show at a glance. - eq := testing.Benchmark(BenchmarkStringEqualityMIT) - act := testing.Benchmark(BenchmarkActiveLicenseMIT) - val := testing.Benchmark(BenchmarkValidateLicensesMIT) - - // Prefer a floating-point ns/op average for display so sub-nanosecond results - // don't get rounded to 0. - eqNsAvg := 0.0 - actNsAvg := 0.0 - valNsAvg := 0.0 - if eq.N > 0 { - eqNsAvg = float64(eq.T.Nanoseconds()) / float64(eq.N) - } - if act.N > 0 { - actNsAvg = float64(act.T.Nanoseconds()) / float64(act.N) - } - if val.N > 0 { - valNsAvg = float64(val.T.Nanoseconds()) / float64(val.N) - } - formatNsAvg := func(ns float64) string { - if ns < 10 { - return fmt.Sprintf("~%.1f ns/op", ns) - } - rounded := int64(math.Round(ns)) - return fmt.Sprintf("~%s ns/op", formatWithCommas(rounded)) - } - formatScale := func(val, baseline float64) string { - if baseline <= 0 { - return "n/a" - } - ratio := val / baseline - if ratio < 1 { - ratio = 1 - } - - // Round to 2 significant digits to match the practical precision of these - // measurements (e.g. 9597 -> 9600, 843 -> 840). - rounded := 0.0 - if ratio >= 10 { - magnitude := math.Pow(10, math.Floor(math.Log10(ratio))-1) // keep 2 sig digits - rounded = math.Round(ratio/magnitude) * magnitude - } else { - // For very small ratios, keep a single decimal place. - rounded = math.Round(ratio*10) / 10 - } - - if rounded >= 10 { - return fmt.Sprintf("~%sx", formatWithCommas(int64(rounded))) - } - if rounded == math.Trunc(rounded) { - return fmt.Sprintf("~%dx", int64(rounded)) - } - return fmt.Sprintf("~%.1fx", rounded) - } - nsOpEq := formatNsAvg(eqNsAvg) - nsOpAct := formatNsAvg(actNsAvg) - nsOpVal := formatNsAvg(valNsAvg) - scaleAct := formatScale(actNsAvg, eqNsAvg) - scaleVal := formatScale(valNsAvg, eqNsAvg) - - fmt.Fprintln(os.Stdout, "\nScalability summary (at a glance)") - - col1 := 22 - col2 := 12 - col3 := 20 - col4 := 28 - - line := func() { - fmt.Fprintf(os.Stdout, "+-%s-+-%s-+-%s-+-%s-+\n", strings.Repeat("-", col1), strings.Repeat("-", col2), strings.Repeat("-", col3), strings.Repeat("-", col4)) - } - row := func(c1, c2, c3, c4 string) { - fmt.Fprintf(os.Stdout, "| %-*s | %-*s | %-*s | %-*s |\n", col1, c1, col2, c2, col3, c3, col4, c4) - } - - line() - row("Characteristic", "MIT==MIT", "activeLicense(\"MIT\")", "ValidateLicenses([\"MIT\"])") - line() - row("ns/op average", nsOpEq, nsOpAct, nsOpVal) - row("Scale", "1x", scaleAct, scaleVal) - row("Time per check", "O(1)", "~O(N*L)", "~O(M*L)") - row("Memory per check", "O(1)", "~O(N) bytes", "~O(M) allocs") - line() - fmt.Fprintln(os.Stdout, "") - fmt.Fprintln(os.Stdout, "Measurement tip: for strict comparisons, keep ops/run equal (-benchtime=1000x) and increase repeats (-count=10+) then compare with benchstat.") - - os.Exit(code) -} - -func formatWithCommas(n int64) string { - s := fmt.Sprintf("%d", n) - if len(s) <= 3 { - return s - } - - var b strings.Builder - pre := len(s) % 3 - if pre == 0 { - pre = 3 - } - b.WriteString(s[:pre]) - for i := pre; i < len(s); i += 3 { - b.WriteByte(',') - b.WriteString(s[i : i+3]) - } - return b.String() -} - -// Benchmark summary (scalability-focused) -// -// BenchmarkStringEqualityMIT measures a constant-time operation: comparing two -// already-in-memory short string literals. This is O(1) time, ~0 allocations, -// and scales linearly only with how many comparisons you do. -// -// BenchmarkActiveLicenseMIT measures checking whether a license ID exists in the -// SPDX active license list via a linear scan with a case-insensitive comparison. -// This is ~O(N*L) time (N = number of license IDs, L = average ID length). -// Note: the generated GetLicenses() currently allocates on each call; see B/op. -// -// BenchmarkValidateLicensesMIT measures SPDX license validation via parsing. -// Even for a single license, this is substantially heavier because it creates -// parser structures and does work proportional to the license string length. -// -// Scalability implications: -// - If you validate M licenses, ValidateLicenses is ~O(M) calls to parse(), so -// total cost grows roughly linearly with M (and with average string length). -// - If license strings are expressions, runtime also scales with expression -// complexity (more tokens/nodes) and may allocate more. -// - The string equality baseline stays near O(1) per comparison with minimal -// memory traffic. -// -// In practice, for “at scale” validation (large M, long expressions, repeated -// checks), the dominant lever is avoiding repeated parsing (e.g., parse once and -// reuse/caching parsed nodes) rather than micro-optimizing string comparisons. -func BenchmarkValidateLicensesMIT(b *testing.B) { - b.ReportAllocs() - - licenses := []string{"MIT"} - - b.ResetTimer() - for i := 0; i < b.N; i++ { - valid, invalid := spdxexp.ValidateLicenses(licenses) - if !valid || len(invalid) != 0 { - b.Fatalf("expected MIT to be valid; valid=%v invalid=%v", valid, invalid) - } - } -} - -func BenchmarkActiveLicenseMIT(b *testing.B) { - b.ReportAllocs() - - id := "MIT" - - b.ResetTimer() - for i := 0; i < b.N; i++ { - ok, matched := spdxexp.ActiveLicense(id) - if !ok || matched != "MIT" { - b.Fatalf("expected MIT to be active; ok=%v matched=%q", ok, matched) - } - } -} - -func BenchmarkStringEqualityMIT(b *testing.B) { - b.ReportAllocs() - - v1 := "MIT" - v2 := "MIT" - - b.ResetTimer() - for i := 0; i < b.N; i++ { - if v1 != v2 { - b.Fatal("unexpected string inequality") - } - } -} diff --git a/spdxexp/benchmark_satisfies_test.go b/spdxexp/benchmark_satisfies_test.go new file mode 100644 index 0000000..646884e --- /dev/null +++ b/spdxexp/benchmark_satisfies_test.go @@ -0,0 +1,493 @@ +package spdxexp + +import ( + "fmt" + "testing" +) + +type satisfiesBenchmarkScenario struct { + name string + testExpression string +} + +var satisfiesBenchmarkScenarios = []satisfiesBenchmarkScenario{ + // Scenario order is used as-is in the summary table. + {"MIT", "MIT"}, + {"mit", "mit"}, + {"Apache-2.0", "Apache-2.0"}, + {"Zed", "Zed"}, + {"MIT AND Apache-2.0", "MIT AND Apache-2.0"}, + {"MIT AND Apache-2.0 OR Zed", "MIT AND Apache-2.0 OR Zed"}, + {"GPL-2.0-or-later", "GPL-2.0-or-later"}, + {"GPL-2.0+", "GPL-2.0+"}, +} + +func BenchmarkSatisfies(b *testing.B) { + for _, scenario := range satisfiesBenchmarkScenarios { + scenario := scenario + b.Run(scenario.name, func(b *testing.B) { + benchmarkSatisfiesScenario(b, scenario.testExpression) + }) + } +} + +func benchmarkSatisfiesScenario(b *testing.B, expression string) { + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + _, err := Satisfies(expression, allowList()) + if err != nil { + b.Fatalf("Satisfies(%q, allowList) error: %v", expression, err) + } + } +} + +func computeSatisfiesBenchmarkTableRows(repeats int) []benchmarkTableRow { + rows := make([]benchmarkTableRow, 0, len(satisfiesBenchmarkScenarios)) + + for _, scenario := range satisfiesBenchmarkScenarios { + scenario := scenario + avg := runBenchmarkNsAvg(repeats, func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := Satisfies(scenario.testExpression, allowList()) + if err != nil { + panic(fmt.Sprintf("Satisfies scenario %q error: %v", scenario.name, err)) + } + } + }) + rows = append(rows, benchmarkTableRow{label: scenario.name, nsOpAvg: avg}) + } + + return rows +} + +func allowList() []string { + // common list of permissive licenses to simulate a realistic use case for Satisfies + return []string{ + "0BSD", + "3D-Slicer-1.0", + "AAL", + "Abstyles", + "AdaCore-doc", + "Adobe-2006", + "Adobe-Display-PostScript", + "Adobe-Glyph", + "Adobe-Utopia", + "ADSL", + "AFL-1.1", + "AFL-1.2", + "AFL-2.0", + "AFL-2.1", + "AFL-3.0", + "Afmparse", + "AMD-newlib", + "AMDPLPA", + "AML", + "AML-glslang", + "AMPAS", + "ANTLR-PD", + "ANTLR-PD-fallback", + "Apache-1.0", + "Apache-1.1", + "Apache-2.0", + "APAFML", + "App-s2p", + "Aspell-RU", + "Baekmuk", + "Bahyph", + "Barr", + "bcrypt-Solar-Designer", + "Beerware", + "Bitstream-Charter", + "Bitstream-Vera", + "blessing", + "BlueOak-1.0.0", + "Boehm-GC", + "Boehm-GC-without-fee", + "Borceux", + "Brian-Gladman-2-Clause", + "Brian-Gladman-3-Clause", + "BSD-1-Clause", + "BSD-2-Clause", + "BSD-2-Clause-Darwin", + "BSD-2-Clause-first-lines", + "BSD-2-Clause-Patent", + "BSD-2-Clause-pkgconf-disclaimer", + "BSD-2-Clause-Views", + "BSD-3-Clause", + "BSD-3-Clause-acpica", + "BSD-3-Clause-Attribution", + "BSD-3-Clause-Clear", + "BSD-3-Clause-flex", + "BSD-3-Clause-HP", + "BSD-3-Clause-LBNL", + "BSD-3-Clause-Modification", + "BSD-3-Clause-Open-MPI", + "BSD-3-Clause-Sun", + "BSD-4-Clause", + "BSD-4-Clause-Shortened", + "BSD-4-Clause-UC", + "BSD-4.3RENO", + "BSD-4.3TAHOE", + "BSD-Advertising-Acknowledgement", + "BSD-Attribution-HPND-disclaimer", + "BSD-Inferno-Nettverk", + "BSD-Source-beginning-file", + "BSD-Source-Code", + "BSD-Systemics", + "BSD-Systemics-W3Works", + "BSL-1.0", + "bzip2-1.0.6", + "Caldera-no-preamble", + "Catharon", + "CC-BY-1.0", + "CC-BY-2.0", + "CC-BY-2.5", + "CC-BY-2.5-AU", + "CC-BY-3.0", + "CC-BY-3.0-AT", + "CC-BY-3.0-AU", + "CC-BY-3.0-DE", + "CC-BY-3.0-IGO", + "CC-BY-3.0-NL", + "CC-BY-3.0-US", + "CC-BY-4.0", + "CC-PDDC", + "CC-PDM-1.0", + "CC0-1.0", + "CDLA-Permissive-1.0", + "CDLA-Permissive-2.0", + "CECILL-B", + "CERN-OHL-1.1", + "CERN-OHL-1.2", + "CERN-OHL-P-2.0", + "CFITSIO", + "check-cvs", + "checkmk", + "Clips", + "CMU-Mach", + "CMU-Mach-nodoc", + "CNRI-Jython", + "CNRI-Python", + "CNRI-Python-GPL-Compatible", + "COIL-1.0", + "Community-Spec-1.0", + "Condor-1.1", + "Cornell-Lossless-JPEG", + "Cronyx", + "Crossword", + "CryptoSwift", + "CrystalStacker", + "Cube", + "curl", + "cve-tou", + "DEC-3-Clause", + "diffmark", + "DL-DE-BY-2.0", + "DL-DE-ZERO-2.0", + "DOC", + "DocBook-DTD", + "DocBook-Schema", + "DocBook-Stylesheet", + "DocBook-XML", + "Dotseqn", + "DRL-1.0", + "DRL-1.1", + "DSDP", + "dtoa", + "dvipdfm", + "ECL-1.0", + "ECL-2.0", + "EFL-1.0", + "EFL-2.0", + "eGenix", + "Entessa", + "EPICS", + "etalab-2.0", + "EUDatagrid", + "Fair", + "FBM", + "Ferguson-Twofish", + "FreeBSD-DOC", + "FSFAP", + "FSFAP-no-warranty-disclaimer", + "FSFUL", + "FSFULLR", + "FSFULLRSD", + "FSFULLRWD", + "FTL", + "Furuseth", + "fwlw", + "Game-Programming-Gems", + "GD", + "generic-xts", + "Giftware", + "Glulxe", + "GLWTPL", + "Graphics-Gems", + "gtkbook", + "Gutmann", + "HaskellReport", + "HDF5", + "hdparm", + "HIDAPI", + "HP-1986", + "HP-1989", + "HPND", + "HPND-DEC", + "HPND-doc", + "HPND-doc-sell", + "HPND-export-US-modify", + "HPND-export2-US", + "HPND-Fenneberg-Livingston", + "HPND-INRIA-IMAG", + "HPND-Intel", + "HPND-Kevlin-Henney", + "HPND-Markus-Kuhn", + "HPND-merchantability-variant", + "HPND-MIT-disclaimer", + "HPND-Netrek", + "HPND-Pbmplus", + "HPND-sell-MIT-disclaimer-xserver", + "HPND-sell-regexpr", + "HPND-sell-variant", + "HPND-sell-variant-MIT-disclaimer", + "HPND-sell-variant-MIT-disclaimer-rev", + "HPND-UC", + "HTMLTIDY", + "IBM-pibs", + "ICU", + "IEC-Code-Components-EULA", + "IJG", + "IJG-short", + "ImageMagick", + "iMatix", + "Info-ZIP", + "Inner-Net-2.0", + "InnoSetup", + "Intel", + "Intel-ACPI", + "ISC", + "ISC-Veillard", + "Jam", + "JasPer-2.0", + "jove", + "JPNIC", + "JSON", + "Kastrup", + "Kazlib", + "Knuth-CTAN", + "Latex2e", + "Latex2e-translated-notice", + "Leptonica", + "Libpng", + "libpng-1.6.35", + "libpng-2.0", + "libselinux-1.0", + "libtiff", + "libutil-David-Nugent", + "Linux-OpenIB", + "LOOP", + "LPD-document", + "lsof", + "Lucida-Bitmap-Fonts", + "LZMA-SDK-9.11-to-9.20", + "LZMA-SDK-9.22", + "Mackerras-3-Clause", + "Mackerras-3-Clause-acknowledgment", + "magaz", + "mailprio", + "man2html", + "Martin-Birgmeier", + "McPhee-slideshow", + "metamail", + "Minpack", + "MIPS", + "MirOS", + "MIT", + "MIT-0", + "MIT-advertising", + "MIT-Click", + "MIT-CMU", + "MIT-enna", + "MIT-feh", + "MIT-Festival", + "MIT-Khronos-old", + "MIT-Modern-Variant", + "MIT-open-group", + "MIT-testregex", + "MIT-Wu", + "MITNFA", + "MMIXware", + "MPEG-SSG", + "mpi-permissive", + "mpich2", + "mplus", + "MS-LPL", + "MS-PL", + "MTLL", + "MulanPSL-1.0", + "MulanPSL-2.0", + "Multics", + "Mup", + "NAIST-2003", + "Naumen", + "NCBI-PD", + "NCL", + "NCSA", + "NetCDF", + "Newsletr", + "ngrep", + "NICTA-1.0", + "NIST-PD", + "NIST-PD-fallback", + "NIST-Software", + "NLOD-1.0", + "NLOD-2.0", + "NLPL", + "NRL", + "NTIA-PD", + "NTP", + "NTP-0", + "O-UDA-1.0", + "OAR", + "ODC-By-1.0", + "OFFIS", + "OFL-1.0", + "OFL-1.0-no-RFN", + "OFL-1.0-RFN", + "OFL-1.1-no-RFN", + "OFL-1.1-RFN", + "OGC-1.0", + "OGDL-Taiwan-1.0", + "OGL-Canada-2.0", + "OGL-UK-1.0", + "OGL-UK-2.0", + "OGL-UK-3.0", + "OLDAP-2.0", + "OLDAP-2.0.1", + "OLDAP-2.1", + "OLDAP-2.2", + "OLDAP-2.2.1", + "OLDAP-2.2.2", + "OLDAP-2.3", + "OLDAP-2.4", + "OLDAP-2.5", + "OLDAP-2.6", + "OLDAP-2.7", + "OLDAP-2.8", + "OLFL-1.3", + "OML", + "OpenSSL", + "OpenSSL-standalone", + "OpenVision", + "OPL-UK-3.0", + "OPUBL-1.0", + "PADL", + "PDDL-1.0", + "PHP-3.0", + "PHP-3.01", + "Pixar", + "pkgconf", + "Plexus", + "pnmstitch", + "PostgreSQL", + "PSF-2.0", + "psfrag", + "psutils", + "Python-2.0", + "Python-2.0.1", + "python-ldap", + "radvd", + "Rdisc", + "RSA-MD", + "Ruby-pty", + "SAX-PD", + "SAX-PD-2.0", + "Saxpath", + "SCEA", + "SchemeReport", + "Sendmail", + "Sendmail-Open-Source-1.1", + "SGI-B-1.1", + "SGI-B-2.0", + "SGI-OpenGL", + "SGP4", + "SHL-0.5", + "SHL-0.51", + "SL", + "SMLNJ", + "snprintf", + "softSurfer", + "Soundex", + "Spencer-86", + "Spencer-94", + "Spencer-99", + "ssh-keyscan", + "SSH-OpenSSH", + "SSH-short", + "SSLeay-standalone", + "Sun-PPP", + "Sun-PPP-2000", + "SunPro", + "SWL", + "swrule", + "Symlinks", + "TCL", + "TCP-wrappers", + "TermReadKey", + "ThirdEye", + "threeparttable", + "TPDL", + "TrustedQSL", + "TTWL", + "TTYP0", + "TU-Berlin-1.0", + "TU-Berlin-2.0", + "UCAR", + "ulem", + "UMich-Merit", + "Unicode-3.0", + "Unicode-DFS-2015", + "Unicode-DFS-2016", + "UnixCrypt", + "Unlicense", + "Unlicense-libtelnet", + "Unlicense-libwhirlpool", + "UPL-1.0", + "VSL-1.0", + "W3C", + "W3C-19980720", + "W3C-20150513", + "w3m", + "Widget-Workshop", + "Wsuipa", + "WTFPL", + "wwl", + "X11", + "X11-distribute-modifications-variant", + "X11-swapped", + "Xdebug-1.03", + "Xerox", + "Xfig", + "XFree86-1.1", + "xinetd", + "xkeyboard-config-Zinoviev", + "xlock", + "Xnet", + "xpp", + "XSkat", + "xzoom", + "Zed", + "Zeeff", + "Zend-2.0", + "Zlib", + "zlib-acknowledgement", + "ZPL-1.1", + "ZPL-2.0", + "ZPL-2.1", + } +} diff --git a/spdxexp/benchmark_setup_test.go b/spdxexp/benchmark_setup_test.go new file mode 100644 index 0000000..85bab37 --- /dev/null +++ b/spdxexp/benchmark_setup_test.go @@ -0,0 +1,195 @@ +package spdxexp + +import ( + "flag" + "fmt" + "math" + "os" + "strings" + "testing" + "time" +) + +const benchmarkRepeatsForSummary = 4 + +// Fixed baselines for the Scale column in the summary tables. +// Using constants makes the scale values comparable across runs/branches. +const ( + benchmarkScaleBaselineValidateLicensesNsOp = 5.0 + benchmarkScaleBaselineSatisfiesNsOp = 1500.0 +) + +func TestMain(m *testing.M) { + // When TestMain is present, it's safest to explicitly parse flags before + // inspecting any -test.* settings. + if !flag.Parsed() { + flag.Parse() + } + + benchPattern := "" + benchFlag := flag.Lookup("test.bench") + if benchFlag != nil { + benchPattern = benchFlag.Value.String() + } + + shouldPrintBenchOutput := benchPattern != "" + if shouldPrintBenchOutput { + // Benchmarks are executed as part of summary table generation (via + // testing.Benchmark). Suppress the default go test benchmark execution so + // we don't run benchmarks twice in a single invocation. + if benchFlag != nil { + _ = benchFlag.Value.Set("$^") + } + + fmt.Fprintln(os.Stdout, "Benchmark summary tables:") + fmt.Fprintln(os.Stdout, "- ns/op average: average time per operation") + fmt.Fprintln(os.Stdout, "- Scale: relative to a fixed baseline per table") + fmt.Fprintln(os.Stdout, "") + } + + code := m.Run() + + if shouldPrintBenchOutput { + validateRows := withScaleColumn(computeValidateLicensesBenchmarkTableRows(benchmarkRepeatsForSummary), benchmarkScaleBaselineValidateLicensesNsOp) + printBenchmarkTable(os.Stdout, "Benchmark ValidateLicenses", validateRows, benchmarkScaleBaselineValidateLicensesNsOp) + + satisfiesRows := withScaleColumn(computeSatisfiesBenchmarkTableRows(benchmarkRepeatsForSummary), benchmarkScaleBaselineSatisfiesNsOp) + printBenchmarkTable(os.Stdout, "Benchmark Satisfies", satisfiesRows, benchmarkScaleBaselineSatisfiesNsOp) + } + + os.Exit(code) +} + +type benchmarkTableRow struct { + label string + nsOpAvg float64 + scale string +} + +func withScaleColumn(rows []benchmarkTableRow, benchmarkScaleBaselineNsOp float64) []benchmarkTableRow { + if len(rows) == 0 { + return rows + } + + for i := range rows { + rows[i].scale = formatScale(rows[i].nsOpAvg, benchmarkScaleBaselineNsOp) + } + return rows +} + +func formatScale(ns, baseline float64) string { + if ns <= 0 || baseline <= 0 { + return "n/a" + } + + ratio := ns / baseline + if ratio >= 0.95 && ratio <= 1.05 { + return "1x" + } + + if ratio >= 10 { + return fmt.Sprintf("~%sx", formatWithCommas(int64(math.Round(ratio)))) + } + + return fmt.Sprintf("~%.1fx", math.Round(ratio*10)/10) +} + +func runBenchmarkNsAvg(repeats int, fn func(b *testing.B)) float64 { + if repeats <= 0 { + repeats = 1 + } + + sum := 0.0 + count := 0 + for i := 0; i < repeats; i++ { + res := testing.Benchmark(fn) + if res.N <= 0 { + continue + } + ns := float64(res.T.Nanoseconds()) / float64(res.N) + sum += ns + count++ + } + if count == 0 { + return 0 + } + return sum / float64(count) +} + +func printBenchmarkTable(w *os.File, title string, rows []benchmarkTableRow, benchmarkScaleBaselineNsOp float64) { + header1 := title + header2 := "ns/op average" + header3 := fmt.Sprintf("Scale (%dns/op=1x)", int(benchmarkScaleBaselineNsOp)) + + col1 := len(header1) + for _, r := range rows { + if len(r.label) > col1 { + col1 = len(r.label) + } + } + + formatNsAvg := func(r benchmarkTableRow) string { + num := nsNumberString(r.nsOpAvg) + return fmt.Sprintf("~%s ns/op", num) + } + + col2 := len(header2) + for _, r := range rows { + if l := len(formatNsAvg(r)); l > col2 { + col2 = l + } + } + + col3 := len(header3) + for _, r := range rows { + if len(r.scale) > col3 { + col3 = len(r.scale) + } + } + + line := func() { + fmt.Fprintf(w, "+-%s-+-%s-+-%s-+\n", strings.Repeat("-", col1), strings.Repeat("-", col2), strings.Repeat("-", col3)) + } + row := func(c1, c2, c3 string) { + fmt.Fprintf(w, "| %-*s | %-*s | %-*s |\n", col1, c1, col2, c2, col3, c3) + } + + line() + row(header1, header2, header3) + line() + for _, r := range rows { + ns := formatNsAvg(r) + row(r.label, ns, r.scale) + } + line() + fmt.Fprintln(w, "") +} + +func nsNumberString(ns float64) string { + if ns <= 0 { + return "0" + } + if ns < float64(10*time.Microsecond.Nanoseconds()) { + return fmt.Sprintf("%.1f", ns) + } + return formatWithCommas(int64(ns + 0.5)) +} + +func formatWithCommas(n int64) string { + s := fmt.Sprintf("%d", n) + if len(s) <= 3 { + return s + } + + var b strings.Builder + pre := len(s) % 3 + if pre == 0 { + pre = 3 + } + b.WriteString(s[:pre]) + for i := pre; i < len(s); i += 3 { + b.WriteByte(',') + b.WriteString(s[i : i+3]) + } + return b.String() +} diff --git a/spdxexp/benchmark_validate_licenses_test.go b/spdxexp/benchmark_validate_licenses_test.go new file mode 100644 index 0000000..0f2124e --- /dev/null +++ b/spdxexp/benchmark_validate_licenses_test.go @@ -0,0 +1,65 @@ +package spdxexp + +import ( + "fmt" + "testing" +) + +type validateLicensesBenchmarkScenario struct { + name string + testLicenses []string +} + +var validateLicensesBenchmarkScenarios = []validateLicensesBenchmarkScenario{ + // Scenario order is used as-is in the summary table. + {"MIT", []string{"MIT"}}, + {"mit", []string{"mit"}}, + {"Apache-2.0", []string{"Apache-2.0"}}, + {"Zed", []string{"Zed"}}, + {"MIT AND Apache-2.0", []string{"MIT", "Apache-2.0"}}, + {"MIT AND Apache-2.0 OR Zed", []string{"MIT", "Apache-2.0", "Zed"}}, + {"GPL-2.0-or-later", []string{"GPL-2.0-or-later"}}, + {"GPL-2.0+", []string{"GPL-2.0+"}}, +} + +func BenchmarkValidateLicenses(b *testing.B) { + for _, scenario := range validateLicensesBenchmarkScenarios { + scenario := scenario + b.Run(scenario.name, func(b *testing.B) { + benchmarkValidateLicensesScenario(b, scenario.testLicenses) + }) + } +} + +func benchmarkValidateLicensesScenario(b *testing.B, licenses []string) { + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + valid, invalidLicenses := ValidateLicenses(licenses) + if !valid || len(invalidLicenses) != 0 { + b.Fatalf("ValidateLicenses(%v) returned valid=%v invalid=%v", licenses, valid, invalidLicenses) + } + } +} + +func computeValidateLicensesBenchmarkTableRows(repeats int) []benchmarkTableRow { + rows := make([]benchmarkTableRow, 0, len(validateLicensesBenchmarkScenarios)) + + for _, scenario := range validateLicensesBenchmarkScenarios { + scenario := scenario + avg := runBenchmarkNsAvg(repeats, func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + valid, invalidLicenses := ValidateLicenses(scenario.testLicenses) + if !valid || len(invalidLicenses) != 0 { + panic(fmt.Sprintf("ValidateLicenses scenario %q failed: valid=%v invalid=%v", scenario.name, valid, invalidLicenses)) + } + } + }) + rows = append(rows, benchmarkTableRow{label: scenario.name, nsOpAvg: avg}) + } + + return rows +} From 28d465d0f7884cbd6172be75903b3206d3830b1e Mon Sep 17 00:00:00 2001 From: "E. Lynette Rayle" Date: Thu, 19 Feb 2026 14:41:58 -0500 Subject: [PATCH 6/6] use MIT shortcut for Satisfies and ValidateLicenses AFTER ``` +----------------------------+---------------+-------------------+ | Benchmark ValidateLicenses | ns/op average | Scale (5ns/op=1x) | +----------------------------+---------------+-------------------+ | MIT | ~5.3 ns/op | ~1.1x | | mit | ~8.2 ns/op | ~1.6x | | Apache-2.0 | ~1536.8 ns/op | ~307x | | Zed | ~3192.3 ns/op | ~638x | | MIT AND Apache-2.0 | ~7676.7 ns/op | ~1,535x | | MIT AND Apache-2.0 OR Zed | ~12,745 ns/op | ~2,549x | | GPL-2.0-or-later | ~2267.0 ns/op | ~453x | | GPL-2.0+ | ~12,118 ns/op | ~2,424x | +----------------------------+---------------+-------------------+ +---------------------------+------------------+----------------------+ | Benchmark Satisfies | ns/op average | Scale (1500ns/op=1x) | +---------------------------+------------------+----------------------+ | MIT | ~1452.5 ns/op | 1x | | mit | ~1495.4 ns/op | 1x | | Apache-2.0 | ~2430.4 ns/op | ~1.6x | | Zed | ~5742.4 ns/op | ~3.8x | | MIT AND Apache-2.0 | ~4,079,706 ns/op | ~2,720x | | MIT AND Apache-2.0 OR Zed | ~4,217,486 ns/op | ~2,812x | | GPL-2.0-or-later | ~9,011,721 ns/op | ~6,008x | | GPL-2.0+ | ~9,509,477 ns/op | ~6,340x | +---------------------------+------------------+----------------------+ ``` --- spdxexp/satisfies.go | 43 ++++++++++++++++++++++++++++++++++++--- spdxexp/satisfies_test.go | 6 +++++- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/spdxexp/satisfies.go b/spdxexp/satisfies.go index abd4778..f2bc9d6 100644 --- a/spdxexp/satisfies.go +++ b/spdxexp/satisfies.go @@ -3,12 +3,26 @@ package spdxexp import ( "errors" "sort" + "strings" ) // ValidateLicenses checks if given licenses are valid according to spdx. // Returns true if all licenses are valid; otherwise, false. // Returns all the invalid licenses contained in the `licenses` argument. func ValidateLicenses(licenses []string) (bool, []string) { + // simple check for MIT covers the most common case and avoids the overhead of parsing for valid licenses + if len(licenses) == 1 && strings.EqualFold(licenses[0], "MIT") { + return true, []string{} + } + + // if only one license, check for active license first since that is the next most common case + if len(licenses) == 1 { + if ok, _ := ActiveLicense(licenses[0]); ok { + return true, []string{} + } + } + + // handle all other cases with parsing, which will cover both single and multiple licenses and expressions valid := true invalidLicenses := []string{} for _, license := range licenses { @@ -24,13 +38,36 @@ func ValidateLicenses(licenses []string) (bool, []string) { // Returns true if allowed list satisfies test license expression; otherwise, false. // Returns error if error occurs during processing. func Satisfies(testExpression string, allowedList []string) (bool, error) { + if len(allowedList) == 0 { + return false, errors.New("allowedList requires at least one element, but is empty") + } + + // simple check for MIT covers the most common case and avoids the overhead of parsing the testExpression + if strings.EqualFold(testExpression, "MIT") { + for _, allowed := range allowedList { + if strings.EqualFold(allowed, "MIT") { + return true, nil + } + } + return false, nil + } + + // if only one license in the test expression, check for active license first to avoid the overhead of parsing + if !strings.Contains(testExpression, " ") { + if ok, _ := ActiveLicense(testExpression); ok { + for _, allowed := range allowedList { + if strings.EqualFold(allowed, testExpression) { + return true, nil + } + } + } + } + + // handle all other cases with parsing, which will cover both single and multiple licenses and expressions expressionNode, err := parse(testExpression) if err != nil { return false, err } - if len(allowedList) == 0 { - return false, errors.New("allowedList requires at least one element, but is empty") - } allowedNodes, err := stringsToNodes(allowedList) if err != nil { return false, err diff --git a/spdxexp/satisfies_test.go b/spdxexp/satisfies_test.go index 0a240d0..e07fa80 100644 --- a/spdxexp/satisfies_test.go +++ b/spdxexp/satisfies_test.go @@ -15,6 +15,9 @@ func TestValidateLicenses(t *testing.T) { allValid bool invalidLicenses []string }{ + {"MIT shortcut test", []string{"MIT"}, true, []string{}}, + {"mit shortcut test", []string{"mit"}, true, []string{}}, + {"Apache-2.0 active shortcut test", []string{"Apache-2.0"}, true, []string{}}, {"All invalid", []string{"MTI", "Apche-2.0", "0xDEADBEEF", ""}, false, []string{"MTI", "Apche-2.0", "0xDEADBEEF", ""}}, {"All valid", []string{"MIT", "Apache-2.0", "GPL-2.0"}, true, []string{}}, {"Some invalid", []string{"MTI", "Apche-2.0", "GPL-2.0"}, false, []string{"MTI", "Apche-2.0"}}, @@ -26,6 +29,7 @@ func TestValidateLicenses(t *testing.T) { "LGPL-2.1-only OR MIT OR BSD-3-Clause", "GPL-2.0-or-later WITH Bison-exception-2.2", }, false, []string{"MIT AND APCHE-2.0"}}, + {"Empty string is invalid", []string{""}, false, []string{""}}, } for _, test := range tests { @@ -73,7 +77,7 @@ func TestSatisfies(t *testing.T) { errors.New("allowedList requires at least one element, but is empty")}, {"err - invalid license", "NON-EXISTENT-LICENSE", []string{"MIT", "Apache-2.0"}, false, errors.New("unknown license 'NON-EXISTENT-LICENSE' at offset 0")}, - {"err - invalid license in allowed list", "MIT", []string{"NON-EXISTENT-LICENSE", "Apache-2.0"}, false, + {"err - invalid license in allowed list", "Apache-1.0", []string{"NON-EXISTENT-LICENSE", "Apache-2.0"}, false, errors.New("unknown license 'NON-EXISTENT-LICENSE' at offset 0")}, {"MIT satisfies [MIT, Apache-2.0]", "MIT", []string{"MIT", "Apache-2.0"}, true, nil},