Skip to content

Commit 5960876

Browse files
bomly-guyclaude
andauthored
refactor: SPDX expression handling is the SDK's, not a local wrapper (#428)
Phase 2.2, which the train skipped: internal/licenseexpr exposed Valid, ValidateAll, Identifier, Compose, Satisfies and Extract, and bomly-sdk/spdxkit exposes the same six with the same signatures. Two implementations of one specification's semantics is the drift this program exists to remove, and it was sitting directly under the next phase's work. The call sites move to the kit, the package is deleted, and go-spdx drops to indirect. No golden moves: the two Compose implementations agree on every fixture the smoke suite produces, which is the check that mattered, since composition is the one function where parenthesization could have differed. The panic guard survives the move -- the kit carries it -- so the rule it protected changes shape rather than disappearing. It used to be "route through the local wrapper"; it is now "do not import the parser at all", and TestNoDirectSPDXExpressionUse moves to internal/detectors/guards_test.go where the other tree-wide structural guards live. It scans test files too, because a test reaching the parser directly proves the same crash is reachable and is where the temptation lives. Mutation-checked by importing go-spdx into internal/sbom. This is the prerequisite for 2.4 (#410): LicenseRef-* minting, hasExtractedLicensingInfos, and mixed-validity composition are all already in spdxkit -- MintLicenseRef, Classify, Compose -- and its doc comments cite the issue by number. Building that on the local wrapper would have meant hand-rolling identifier sanitization and collision-avoidance beside a library that already owns both. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 6adc734 commit 5960876

13 files changed

Lines changed: 80 additions & 324 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ See [`dev-docs/ARCHITECTURE.md`](dev-docs/ARCHITECTURE.md) for full detail (the
6767
| `internal/baseline` | Portable package-finding baseline codec and audit-integrated policy-status resolver |
6868
| `internal/remediation` | Canonical vulnerability fix status, version, detector-hint validation, and occurrence suggestions |
6969
| `internal/sbom` | SBOM codec (SPDX 2.3, CycloneDX) |
70-
| `internal/licenseexpr` | SPDX license expression parsing and identifier classification (guards the parser's panics) |
7170
| `internal/benchmark` | Hidden local dependency-graph benchmark, baseline comparison, scoring, and embedded presets |
7271
| `internal/output` | Output rendering plus structured command payloads and schema generation for `scan`, `diff`, `explain`, JSON, and SARIF 2.1.0 |
7372
| `internal/plugin` | Plugin discovery, protocol, handshake, and pooled subprocess execution |
@@ -111,7 +110,7 @@ Runtime preparation is owned by `internal/engine`: build the filtered registry o
111110
- `internal/testnodes` is test-only: it routes fixture shapes through the real node constructors, panicking rather than taking a `testing.TB` so a table entry stays one expression. Label lookups ("name@version" to the canonical package URLs node IDs now are) delegate to `bomly-sdk/testkit` — the matching rules have one home, not two. Non-test code must not import it.
112111
- `internal/baseline` owns the baseline document and matching implementation. It depends on the SDK policy contracts and must not be imported by `internal/engine`.
113112
- `internal/remediation` owns canonical vulnerability remediation decisions. Detectors may supply validated read-only strategy hints, but they do not choose final actions or versions.
114-
- `internal/licenseexpr` owns all SPDX license expression parsing. The underlying parser panics on some malformed input, and license strings come from untrusted lockfiles and registry APIs, so no other package under `internal/` may import `github.com/github/go-spdx` directly; `TestNoDirectSPDXExpressionUse` enforces this.
113+
- SPDX license expression handling is `bomly-sdk/spdxkit`'s: validation, identifier classification, composition, deprecated-ID canonicalization, and `LicenseRef-*` minting. The underlying parser panics on some malformed input, and license strings come from untrusted lockfiles and registry APIs, so no package under `internal/` may import `github.com/github/go-spdx` directly — the kit carries the panic guard. `TestNoDirectSPDXExpressionUse` (in `internal/detectors/guards_test.go`) enforces this across the whole tree, test files included. The CLI's own `internal/licenseexpr` wrapper is deleted; it duplicated the kit function for function.
115114
- `internal/registry` owns package-manager discovery, support lookups, and built-in registry wiring in `internal/registry/builder.go`. Do not create or reintroduce a separate `registrybuilder` package.
116115
- `internal/engine` may import `internal/detectors` and `internal/registry`, but detector packages must not point back into `internal/engine`. Runtime planning, prepared subprojects, and detector-chain reuse belong in `internal/engine`.
117116

CLAUDE.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ See [`dev-docs/ARCHITECTURE.md`](dev-docs/ARCHITECTURE.md) for full detail (the
6767
| `internal/baseline` | Portable package-finding baseline codec and audit-integrated policy-status resolver |
6868
| `internal/remediation` | Canonical vulnerability fix status, version, detector-hint validation, and occurrence suggestions |
6969
| `internal/sbom` | SBOM codec (SPDX 2.3, CycloneDX) |
70-
| `internal/licenseexpr` | SPDX license expression parsing and identifier classification (guards the parser's panics) |
7170
| `internal/benchmark` | Hidden local dependency-graph benchmark, baseline comparison, scoring, and embedded presets |
7271
| `internal/output` | Output rendering plus structured command payloads and schema generation for `scan`, `diff`, `explain`, JSON, and SARIF 2.1.0 |
7372
| `internal/plugin` | Plugin discovery, protocol, handshake, and pooled subprocess execution |
@@ -111,7 +110,7 @@ Runtime preparation is owned by `internal/engine`: build the filtered registry o
111110
- `internal/testnodes` is test-only: it routes fixture shapes through the real node constructors, panicking rather than taking a `testing.TB` so a table entry stays one expression. Label lookups ("name@version" to the canonical package URLs node IDs now are) delegate to `bomly-sdk/testkit` — the matching rules have one home, not two. Non-test code must not import it.
112111
- `internal/baseline` owns the baseline document and matching implementation. It depends on the SDK policy contracts and must not be imported by `internal/engine`.
113112
- `internal/remediation` owns canonical vulnerability remediation decisions. Detectors may supply validated read-only strategy hints, but they do not choose final actions or versions.
114-
- `internal/licenseexpr` owns all SPDX license expression parsing. The underlying parser panics on some malformed input, and license strings come from untrusted lockfiles and registry APIs, so no other package under `internal/` may import `github.com/github/go-spdx` directly; `TestNoDirectSPDXExpressionUse` enforces this.
113+
- SPDX license expression handling is `bomly-sdk/spdxkit`'s: validation, identifier classification, composition, deprecated-ID canonicalization, and `LicenseRef-*` minting. The underlying parser panics on some malformed input, and license strings come from untrusted lockfiles and registry APIs, so no package under `internal/` may import `github.com/github/go-spdx` directly — the kit carries the panic guard. `TestNoDirectSPDXExpressionUse` (in `internal/detectors/guards_test.go`) enforces this across the whole tree, test files included. The CLI's own `internal/licenseexpr` wrapper is deleted; it duplicated the kit function for function.
115114
- `internal/registry` owns package-manager discovery, support lookups, and built-in registry wiring in `internal/registry/builder.go`. Do not create or reintroduce a separate `registrybuilder` package.
116115
- `internal/engine` may import `internal/detectors` and `internal/registry`, but detector packages must not point back into `internal/engine`. Runtime planning, prepared subprojects, and detector-chain reuse belong in `internal/engine`.
117116

dev-docs/ARCHITECTURE.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,6 @@ Cache failures are non-fatal. The command should warn and continue rather than f
247247
| `internal/engine/scan` | Scan command pipeline API |
248248
| `internal/output` | Text, JSON, SARIF rendering, plus structured response payloads and schema generation |
249249
| `internal/sbom` | SPDX and CycloneDX codecs |
250-
| `internal/licenseexpr` | SPDX license expression parsing and identifier classification |
251250
| `internal/benchmark` | Hidden local dependency-graph benchmark, baseline comparison, scoring, and embedded presets |
252251
| `sdk` | Shared domain types |
253252
| `internal/plugin` | Managed plugin manifests, installation, verification, store state, adapters, and runtime glue |

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ require (
1919
github.com/charmbracelet/bubbles v1.0.0
2020
github.com/charmbracelet/bubbletea v1.3.10
2121
github.com/charmbracelet/lipgloss v1.1.0
22-
github.com/github/go-spdx/v2 v2.7.0
2322
github.com/hashicorp/go-hclog v1.6.3
2423
github.com/hashicorp/go-plugin v1.8.0
2524
github.com/mark3labs/mcp-go v0.58.0
@@ -150,6 +149,7 @@ require (
150149
github.com/felixge/httpsnoop v1.0.4 // indirect
151150
github.com/fsnotify/fsnotify v1.9.0 // indirect
152151
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
152+
github.com/github/go-spdx/v2 v2.7.0 // indirect
153153
github.com/glebarez/go-sqlite v1.22.0 // indirect
154154
github.com/glebarez/sqlite v1.11.0 // indirect
155155
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect

internal/auditors/license/auditor.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import (
77
"fmt"
88
"strings"
99

10-
"github.com/bomly-dev/bomly-cli/internal/licenseexpr"
1110
"github.com/bomly-dev/bomly-sdk"
11+
"github.com/bomly-dev/bomly-sdk/spdxkit"
1212
)
1313

1414
const (
@@ -100,15 +100,15 @@ func (a Auditor) Audit(_ context.Context, req sdk.AuditRequest) (sdk.AuditResult
100100
findings = append(findings, finding(purl, dep.NodeID(), "unknown-license", "Package license is unknown", sdk.FindingPolicyStatusWarn))
101101
continue
102102
}
103-
valid, invalid := licenseexpr.ValidateAll(licenses)
103+
valid, invalid := spdxkit.ValidateAll(licenses)
104104
if !valid {
105105
findings = append(findings, finding(purl, dep.NodeID(), "invalid-license", "Package has invalid SPDX license: "+strings.Join(invalid, ", "), sdk.FindingPolicyStatusFail))
106106
continue
107107
}
108108
if len(a.AllowLicenses) > 0 {
109109
allowed := false
110110
for _, expr := range licenses {
111-
ok, err := licenseexpr.Satisfies(expr, a.AllowLicenses)
111+
ok, err := spdxkit.Satisfies(expr, a.AllowLicenses)
112112
if err == nil && ok {
113113
allowed = true
114114
break
@@ -121,7 +121,7 @@ func (a Auditor) Audit(_ context.Context, req sdk.AuditRequest) (sdk.AuditResult
121121
}
122122
if len(a.DenyLicenses) > 0 {
123123
for _, expr := range licenses {
124-
used, err := licenseexpr.Extract(expr)
124+
used, err := spdxkit.Extract(expr)
125125
if err != nil {
126126
continue
127127
}

internal/detectors/guards_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,54 @@ func TestExportNeverReadsResolvedURL(t *testing.T) {
8787
t.Fatalf("the export layer references ResolvedURL; it must read Origin.Normalized() only: %v", offenders)
8888
}
8989
}
90+
91+
// The SPDX expression parser panics on some malformed input, and license
92+
// strings arrive from lockfiles and registry APIs that a repository controls,
93+
// so no package here may call it directly.
94+
//
95+
// The guard used to live in internal/licenseexpr, which wrapped the parser and
96+
// caught its panics. That package is gone: bomly-sdk/spdxkit owns expression
97+
// handling now, panic guard included, so the rule is no longer "route through
98+
// the local wrapper" but "do not import the parser at all". Written as a
99+
// module-path check rather than an import-list scan of one package, because
100+
// the point is that nothing under internal/ reaches the parser by any route.
101+
func TestNoDirectSPDXExpressionUse(t *testing.T) {
102+
const spdxModule = "github.com/github/go-spdx"
103+
104+
// This file names the module in the rule it enforces, so it is the one
105+
// exemption -- the same shape the guard had when it lived beside the
106+
// wrapper it policed.
107+
self := "guards_test.go"
108+
109+
var offenders []string
110+
walkInternalGo(t, func(path, body string) {
111+
if strings.Contains(body, spdxModule) {
112+
offenders = append(offenders, path)
113+
}
114+
})
115+
// Test files too: a test reaching the parser directly proves the same
116+
// crash is reachable, and it is where the temptation lives.
117+
err := filepath.Walk(internalRoot, func(path string, info os.FileInfo, err error) error {
118+
if err != nil {
119+
return err
120+
}
121+
if info.IsDir() || !strings.HasSuffix(path, "_test.go") || filepath.Base(path) == self {
122+
return nil
123+
}
124+
body, err := os.ReadFile(path)
125+
if err != nil {
126+
return err
127+
}
128+
if strings.Contains(string(body), spdxModule) {
129+
offenders = append(offenders, path)
130+
}
131+
return nil
132+
})
133+
if err != nil {
134+
t.Fatalf("walk tests: %v", err)
135+
}
136+
if len(offenders) > 0 {
137+
t.Fatalf("these files reference %s directly; the parser panics on malformed input, "+
138+
"so go through bomly-sdk/spdxkit instead: %v", spdxModule, offenders)
139+
}
140+
}

internal/licenseexpr/licenseexpr.go

Lines changed: 0 additions & 117 deletions
This file was deleted.

0 commit comments

Comments
 (0)