Skip to content

refactor(detectors): the SDK decides every detector's purl type - #446

Merged
bomly-guy merged 4 commits into
mainfrom
claude/purl-type-guard
Sep 12, 2026
Merged

bomly-guy merged 4 commits into
mainfrom
claude/purl-type-guard

Conversation

@bomly-guy

Copy link
Copy Markdown
Collaborator

The defect

Fourteen call sites across twelve detector files spelled their ecosystem's package-url type as a string literal in the first argument to sdk.BuildPackageURL:

PURL: sdk.BuildPackageURL("cocoapods", "", name, version),

That literal is a mapping decision — ecosystem to purl type — and it is exactly the second mapping table the SDK maturity program set out to delete. The first one is bomly-sdk/purlkit.TypeForValues, whose own doc comment calls it "the one authority for this mapping (ADR-0038)". The second one had no home, no tests, and no reviewer: it was whatever the person writing a detector typed that day.

It is the kind of table that is wrong without looking wrong. Nothing reads oddly about pkg:generic until an advisory fails to match — which is how go4.org went out under that type (bomly-sdk#67).

The swift pair is the standing trap and shows why the rule cannot be a review habit. cocoapods/detector.go and swiftpm/detector.go sit next to each other, both carry sdk.EcosystemSwift, and they need different purl types — cocoapods and swift. purlkit has deliberately no "swift" case precisely so the package manager is what decides. A detector hand-mapping from its ecosystem alone gets one of the two wrong, silently.

The change

Every site now hands the SDK the ecosystem and package-manager values it already held:

PURL: sdk.BuildPackageURL(sdk.PackageURLTypeForValues(sdk.EcosystemSwift, sdk.PackageManagerCocoaPods), "", name, version),

Nothing is re-derived — each detector already had both tokens in the same sdk.Coordinates literal.

Before / after types — all fourteen sites

Probed against the pinned bomly-dev/bomly-sdk v0.9.7 with each site's actual tokens, in both argument orders, so the result does not depend on the order I happened to pass them in.

# Site Literal (before) Ecosystem PackageManager.Name() SDK type (after) Changed?
1 python/piplock.go:78 pypi python pip pypi no
2 python/poetrylock.go:89 pypi python poetry pypi no
3 cargo/detector.go:472 cargo rust cargo cargo no
4 cargo/detector.go:590 cargo rust cargo cargo no
5 cargo/workspace.go:243 cargo rust cargo cargo no
6 cargo/workspace.go:257 cargo rust cargo cargo no
7 composer/detector.go:308 composer php composer composer no
8 mix/detector.go:411 hex elixir mix hex no
9 nuget/detector.go:554 nuget dotnet nuget nuget no
10 conan/detector.go:328 conan cpp conan conan no
11 sbt/detector.go:183 maven scala sbt maven no
12 pub/detector.go:199 pub dart pub pub no
13 cocoapods/detector.go:355 cocoapods swift cocoapods cocoapods no
14 swiftpm/detector.go:293 swift swift swiftpm swift no

No emitted purl type changes. No node ID, OSV match, or SBOM reference moves, and no golden shifts — make verify confirms.

Two results worth stating rather than assuming, because they were the flagged traps:

  • cocoapods vs swift. Both detectors carry ecosystem swift. purlkit has no "swift" case, so the ecosystem token falls through and the package manager answers: cocoapods → cocoapods, swiftpm → swift. Had either site passed only its ecosystem, CocoaPods would have silently started minting pkg:swift. It passes both.
  • mix/hex. purlkit's refusal to guess between Elixir and Erlang is on the reverse join (EcosystemForType("hex") returns unknown by decision). The forward direction is unambiguous: elixir and mix both map to hex, matching the literal. Nothing changes here.

The guard

TestDetectorPURLTypesComeFromTheSDK in internal/detectors/guards_test.go, alongside the existing guards.

It is written as a positive requirement — the first argument to BuildPackageURL must be the call to sdk.PackageURLTypeForValues — rather than as "no string literal in that position". A negative rule is escaped by lifting the literal into a const one line up, which renames the defect instead of removing it. A const identifier is not a call to the authority either, so the positive form has no such escape.

It is AST-based, not textual, and matches the callee by name with the package qualifier dropped, so an import alias or a local wrapper does not walk past it. A second check reports a string literal passed to PackageURLTypeForValues, since restating the answer as the question is the same hand-mapping wearing the authority's name.

Scoped to internal/detectors/: that is where the duplicated table lived and where new ones get written. Ingest is a different question — a document under internal/sbom arrives carrying a type somebody else chose — and a rule about minting has nothing to say about reading.

TestPURLTypeGuardSeesTheShapesItForbids pins the predicate on fixtures in both directions, because a rule only ever exercised against a tree that already passes is a rule nobody has seen fail — which is this file's documented failure mode.

Mutation results

This file has four recorded cases of a guard passing while the thing it forbade was present, so the guard was mutation-tested against real production code. Each mutation was applied on its own, verified to still compile (go build ./...), and reverted:

# Mutation Compiles? Guard
M1 Bare literal restored: BuildPackageURL("cocoapods", …) in cocoapods/detector.go yes FAIL — cocoapods/detector.go:355 mints a package URL with a type sdk.PackageURLTypeForValues did not decide
M2 Literal hoisted into a package const swiftPURLType = "swift" in swiftpm/detector.go yes FAIL — swiftpm/detector.go:293 mints a package URL with a type sdk.PackageURLTypeForValues did not decide
M3 Literal handed to the authority: PackageURLTypeForValues("hex") in mix/detector.go yes FAIL — mix/detector.go:411 passes the literal "hex" to sdk.PackageURLTypeForValues instead of the detector's own ecosystem and package-manager values
M4 Mint hidden behind a local helper pubPURL(purlType, …) in pub/detector.go yes FAIL — pub/detector.go:284 mints a package URL with a type sdk.PackageURLTypeForValues did not decide
— Control: clean tree yes PASS

M2 is the one that matters most: it is the mutation a regex-based "no literal here" guard would have passed.

Verification

make verify passes end to end — fmt-check, golangci-lint (0 issues), go vet for the full, lite and smoke build tags, both builds, go test ./..., and make generate with no docs drift.

Scope

Closes the open half of dev-docs/SDK_MATURITY_PLAN.md §6 criterion 3: "zero purl-type string literals in detectors … enforced by a guard test, not a review habit".

🤖 Generated with Claude Code

Fourteen call sites across twelve detectors spelled their ecosystem's
package-url type as a string literal in the first argument to
sdk.BuildPackageURL. That is a second mapping table -- maintained by
whoever happened to be writing a detector, next to purlkit, which the SDK
calls the one authority for this mapping (ADR-0038). It is the shape that
minted go4.org as pkg:generic (bomly-sdk#67), and the swift pair is the
standing trap: CocoaPods and SwiftPM share the ecosystem token "swift"
but need the types "cocoapods" and "swift", which only the package
manager distinguishes.

Every site now passes the ecosystem and package-manager values it already
held to sdk.PackageURLTypeForValues. All fourteen emitted types are
unchanged -- verified against the SDK for each site's tokens, in both
argument orders -- so no node ID, OSV match, or SBOM reference moves.

TestDetectorPURLTypesComeFromTheSDK enforces it from here, as a positive
requirement rather than a ban on literals: the first argument must be the
call to the SDK, so lifting a literal into a const does not escape it.
The predicate is pinned on fixtures, because a rule only ever run against
a passing tree is a rule nobody has seen fail.

Closes the last open half of SDK maturity phase 2.1, criterion 3.

Co-Authored-By: Claude Opus 5 <[email protected]>
@coderabbitai

coderabbitai Bot commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 33 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: b2f5883c-c60f-4634-bd22-1c5dc0f910ca

📥 Commits

Reviewing files that changed from the base of the PR and between de08fc2 and e490259.

📒 Files selected for processing (13)
  • internal/detectors/cargo/detector.go
  • internal/detectors/cargo/workspace.go
  • internal/detectors/cocoapods/detector.go
  • internal/detectors/composer/detector.go
  • internal/detectors/conan/detector.go
  • internal/detectors/guards_test.go
  • internal/detectors/mix/detector.go
  • internal/detectors/nuget/detector.go
  • internal/detectors/pub/detector.go
  • internal/detectors/python/piplock.go
  • internal/detectors/python/poetrylock.go
  • internal/detectors/sbt/detector.go
  • internal/detectors/swiftpm/detector.go

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

Bomly Diff Summary

Compared de08fc2e6702e59cac1380b4db4188e93928624f to e490259f834b5de0d6319b6874a198626c7c12ae.

Overview

Status Manifests Dependencies Findings Duration
✅ Pass +0 / ~0 / -0 0 added / 0 version changed / 0 detail changes / 0 removed 0 introduced / 0 persisted / 0 resolved 1m 26s

Dependency Changes

✅ No dependency changes.

Vulnerabilities

✅ No vulnerability changes.

License Changes

✅ No license changes.

Project Posture

✅ No project posture changes (--matchers +scorecard was not selected).

Policy Findings

✅ No policy differences were identified.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4c51b1e00e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/detectors/guards_test.go
Codex found a signposted way around it. The guard recognized only
BuildPackageURL, so a detector could call purlkit.Build with a
hand-picked Type and every check stayed green -- and the concatenation
guard three rules down recommends purlkit.Build by name, so this is the
route the file itself points at, not an obscure one.

Reproduced before changing anything: a pub detector minting
pkg:generic through purlkit.Build compiles and passes the whole suite.

The rule now inspects the Type field of a purlkit.PURL literal with the
same positive requirement the mint site carries -- Type must be a
PackageURLTypeForValues call, so hoisting the answer into a const does
not escape it either. The composite literal's type name is checked, not
just the callee, because Build is a common enough name that matching it
alone would report unrelated builders that happen to take a struct.

Four fixtures added, in both directions: a hand-picked Type is reported,
a delegating one is not -- forbidding that would ban the construction the
codebase is being moved onto -- a const-hoisted Type is reported, and an
unrelated Build taking a struct is not.

Mutations: Codex's escape now fails, naming the file and line; dropping
the composite type-name check fails on the unrelated builder.

Co-Authored-By: Claude Opus 5 <[email protected]>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ceaf18ae28

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/detectors/guards_test.go Outdated
Codex again, and again on my own fix. Watching purlkit.Build's arguments
only saw the shape written inline: assign the literal to a variable first
and the guard read an identifier, found no composite literal, and passed.
Reproduced before changing anything.

Tracing the value was the obvious repair and it is the wrong one. The
type is chosen where the literal is written, not where Build is called,
so the rule keys on purlkit.PURL literals wherever they appear --
inline, assigned, or returned by a helper -- and the hop stops mattering.

My first attempt did trace, conservatively rejecting arguments it could
not read. Its own fixtures caught it reporting a *delegating* value
passed through a variable: wrong in both directions, since it would also
have missed a literal built one file over. The Build branch is gone; the
literal rule alone is shorter and stronger.

Field assignment after construction is covered too. Nothing under
internal/detectors sets a .Type from a literal today, so the rule costs
nothing now and closes the next hop before somebody takes it.

Mutations: the identifier bypass fails; the field-assignment path fails;
a literal branch that stops reading Type fails.

Co-Authored-By: Claude Opus 5 <[email protected]>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 09f646cb09

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/detectors/guards_test.go Outdated
Three review rounds found three ways a detector could pick a purl type
through purlkit -- a literal at the call, a literal assigned first, a
field set afterwards -- and each fix added a shape to an AST pattern. A
fourth round found a fourth, which we filed rather than patched. Chasing
shapes was the defect.

Detectors have no business holding purlkit. They ask the SDK for a
package URL; purlkit is where one is built, so holding it is holding the
ability to choose a type by hand in more shapes than a pattern can
enumerate. No detector imports it today, so the rule costs nothing and
closes every shape at once, including the ones nobody has thought of.

Scoped to detectors. internal/sbom, internal/output and internal/auditors
use purlkit legitimately: they read and render package URLs minted
elsewhere, and a minting rule has nothing to say about reading.

The AST rule keeps only what the import ban cannot cover -- a literal
handed to sdk.BuildPackageURL, and a literal handed to the authority
itself -- and loses the two branches that leaked, along with their
helpers. It went from 88 lines to a third of that.

No entitlement was added for this file. It names purlkit in a string and
does not import it, which is exactly the distinction the entitlement
rules already make.

Mutations, all five reported: a literal at the mint site; purlkit.Build
inline; the same through a variable; a field set after construction; and
a type taken from a const, which is the gap #449
records as needing go/types. It needs no type information at all once the
import is gone.

Co-Authored-By: Claude Opus 5 <[email protected]>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e490259f83

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/detectors/guards_test.go
@bomly-guy
bomly-guy merged commit 307ac5c into main Sep 12, 2026
16 checks passed
@bomly-guy
bomly-guy deleted the claude/purl-type-guard branch September 12, 2026 06:53
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