Skip to content

feat(detectors): record which module root each location belongs to (phase 2.8, producer half) - #439

Merged
bomly-guy merged 4 commits into
mainfrom
claude/phase-2.8a-detector-attribution
Sep 9, 2026
Merged

bomly-guy merged 4 commits into
mainfrom
claude/phase-2.8a-detector-attribution

Conversation

@bomly-guy

Copy link
Copy Markdown
Collaborator

Producer half of SDK maturity plan row 2.8. Native detectors now record, on every PackageLocation they emit, the module root whose resolution produced the site, that site's relationship, and that site's scopes — the join key ADR-0037 ("Usage facts carry their attribution") defines as (module root, declaration site).

What attribution now lands

One shared helper, detectors.Attributed (internal/detectors/attribution.go), is the only place the derivation lives; every detector returns its sdk.DetectionResult through it, and a guard test (TestDetectionResultsCarryingGraphsAreAttributed) fails when one does not.

  • Module root — the directory of the module node's declaring manifest (. for the detector's own working directory, apps/web for a workspace member, module-a for a Maven reactor project).
  • Relationship — derived from that root's own edges: direct when the root declares it, transitive when reached through another dependency, and unknown preserved when the detector already said the parent could not be recovered. Deliberately not sdk.RelationshipForPath, which prefers the node's stored scalar — the merged value that cannot tell one root's declaration from another root's transitive path. That decline is recorded in the code.
  • Scopes — precise when the detector supplies per-module declarations (npm does, via ModuleDeclarations); otherwise the node's union, but only when exactly one module root reaches the node. When several do, the union mixes roots and the site claims no scope rather than a scope nobody observed there.
  • Ownership of a site — a file inside a module's own directory belongs to that module alone (a Maven sibling does not claim module-a/pom.xml); a file at the top of the scan is shared, so each workspace member that reaches a lockfile line gets its own record of it.

internal/engine/consolidation rebases the module root next to the paths it already rebases: every detector calls its own working directory ., and two subprojects both claiming . would make the key useless the moment a recursive scan merges them. That is the only engine change; the new fields otherwise survive consolidation untouched (verified).

The row 2.8 regression case

TestNPMLockfileWorkspaceRecordsOneLocationPerModuleUsage (new fixture npm-v3-workspaces-cross-scope): shared-tool is a direct development dependency of apps/web and a transitive runtime dependency of packages/lib. It comes back as two records of the same lockfile line — {apps/web, direct, [development]} and {packages/lib, transitive, [runtime]} — while the node's own scope union keeps both, unchanged.

TestMavenSharedDependencyGetsOneRecordPerReactorModule is the reactor form: one artifact, one record per reactor module, each pointing at its own pom, and no invented scope because Maven passes no declarations.

Which detectors attribute, and which cannot

Attributing (module root + relationship on every location they emit): cargo, cocoapods, composer, conan, go, gradle, maven, mix, npm, nuget, pnpm, poetry, pip, pipenv, pub, ruby, sbt, swiftpm, uv, yarn.

Not attributing, exemption recorded in the file and named by the guard test:

  • sbom — it converts a document, it does not resolve a project. Whatever module produced those packages was resolved elsewhere by something else; the paths are the producer's, not this scan's.
  • github-actions — a workflow is not a module. Its root is the workflow file, so the only available "module root" would be .github/workflows, a directory that declares no module.
  • bun — attaches no positions, so it emits no locations for attribution to land on. Nothing to attribute until it does.

Scope precision beyond the module root is currently supplied only by npm; pnpm, yarn, cargo and maven have per-module declarations available and could be upgraded the same way in a follow-up.

Golden movement — read this before merging

make verify passes with no drift: internal/output.LocationRef projects only real_path, access_path and position, so single-module scan JSON is byte-identical.

Two smoke goldens do move, and I did not regenerate them: scan-npm-workspaces and scan-cargo-workspace. Because the projection drops the attribution, the shared-lockfile records for two workspace members render as duplicate location entries — a diff that shows something other than added attribution, which is a bug rather than drift. The fix belongs to the consumer half of row 2.8 (project module_root / scopes / relationship into LocationRef), which is out of this PR's scope.

So: this PR should land together with, or after, the consumer-half change that projects the attribution, and those two goldens should be regenerated then. Alone it leaves those two smoke cases red and workspace scan JSON showing duplicated-looking locations.

Related SDK gap found while verifying (recorded in the code, worth an SDK issue): hasDependencyLocation compares paths and position only, so merging two separate nodes that each carry one root's record drops the second. Workspace detectors partition with shared node pointers, so the case above survives; two detectors resolving one package from one path would not.

Mutations run

Each applied alone, tree kept compiling, then reverted:

# Mutation Result
M1 direct children recorded as transitive fails TestAttributedRecordsTheModuleRootAndDirectness, npm, maven, gomod
M2 site scopes always empty fails TestAttributedGivesEachModuleRootItsOwnRecord, cycle test, npm
M3 never add a second record for a shared site fails both npm workspace tests and both multi-root helper tests
M4 every root claims every site fails TestAttributedLeavesAnotherModulesFileAlone, maven
M5 module root not rebased for a subproject fails TestRebaseGraphLocations_MovesModuleRootIntoRepositoryCoordinates
M6 Attributed does nothing fails all 8 helper tests, all 13 detector-table cases, npm, maven, gomod
M7 (first attempt) skip the Name lookup only passed — a non-mutation: QualifiedName() catches it. Redone as M7b
M7b declared scopes never consulted fails TestAttributedGivesEachModuleRootItsOwnRecord, cycle test, npm
M8 claim the node union even when several roots reach it fails TestAttributedLeavesScopesEmptyWhenTheUnionMixesModules, maven
M9 module roots normalized to a wrong directory fails every attribution test
M10 one detector's wrapper removed (parenthesized form) fails the detector table; the guard regex does not match this form
M11 one detector forgets Attributed (the realistic form) fails TestDetectionResultsCarryingGraphsAreAttributed

Delegation check

Ran the delegation-check skill before implementing. Outcome: no new grammar, vocabulary, or format is introduced. The scope lattice, relationship vocabulary and merge rules are the SDK's and are used through it (sdk.MergeScope, sdk.ScopesOf, sdk.DependencyRelationship*, sdk.AsDependencyNode); path handling is path/strings. One decline is recorded in the code: sdk.RelationshipForPath is not used for per-site directness because it prefers the stored merged scalar, which is exactly the lossy summary this change exists to replace. No parser added, so no new fuzz target is required.

🤖 Generated with Claude Code

Scope, relationship and reachability answer questions about a usage, not
about a package. Today a node carries a scope union, one merged
relationship scalar, and nothing tying either to the module whose
resolution observed it -- so "reachable, runtime and direct" can come back
true when no single usage satisfies all three: reachable in one module,
runtime in another, direct in a third. ADR-0037 fixes that by making the
usage unit (module root, declaration site) and storing the module root on
the location, because two modules can share one lockfile and the path alone
cannot say whose usage a line is.

This is the producer half. Native detectors now stamp, on every location
they emit, the module root that reached the site, the relationship that
root has to the package, and the scopes of that particular usage. The
node-level union and scalar are untouched; the per-site record sits beside
them.

The derivation lives in one place, detectors.Attributed, and every detector
returns its result through it. It walks each module node in the result,
derives directness from that root's own edges -- deliberately not
sdk.RelationshipForPath, which prefers the stored scalar and so cannot tell
one root's declaration from another root's transitive path -- and decides
what it may honestly say about scopes: a detector that reads per-module
declarations passes them in and gets the real per-site scope; without them
the node's union is only that root's view when no other root reaches the
node, and otherwise no scope is claimed at all. A guard test fails when a
detector returns graphs without going through the helper, because the rule
is one line and one line is what gets forgotten.

A site inside a module's own directory belongs to that module alone, so a
Maven reactor sibling does not claim module-a/pom.xml. A site at the top of
the scan is shared, so each npm workspace member that reaches a lockfile
line gets its own record of it -- which is what makes a package that is a
direct development dependency of one member and a transitive runtime
dependency of another come back as two distinguishable usages.

Two detectors are exempt, with the reason recorded in each file and named
by the guard: an ingested SBOM's packages were resolved elsewhere, and a
GitHub Actions workflow is not a module. An empty module root is the honest
record of a site nobody attributed.

Consolidation rebases the module root alongside the paths it already
rebases. A detector names its own working directory ".", and two
subprojects both claiming "." would make the join key useless the moment a
recursive scan merges them.

Co-Authored-By: Claude Opus 5 <[email protected]>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 52 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: 3f8c755b-5c0b-488b-9fa2-1a4554ee8a18

📥 Commits

Reviewing files that changed from the base of the PR and between 714068f and ba20cf6.

⛔ Files ignored due to path filters (45)
  • docs/schemas/diff.md is excluded by !docs/schemas/**
  • docs/schemas/diff.schema.json is excluded by !docs/schemas/**
  • docs/schemas/explain.md is excluded by !docs/schemas/**
  • docs/schemas/explain.schema.json is excluded by !docs/schemas/**
  • docs/schemas/scan.md is excluded by !docs/schemas/**
  • docs/schemas/scan.schema.json is excluded by !docs/schemas/**
  • internal/detectors/node/testdata/lockfiles/npm-v3-workspaces-cross-scope/package-lock.json is excluded by !**/package-lock.json, !**/testdata/**
  • test/smoke/testdata/golden/diff-go-audit.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/diff-go.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/diff-npm.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/explain-go-enrich.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/explain-go.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/finding-baseline-workflow.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/lite-diff-go.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/lite-explain-go.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/lite-scan-go.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-bundler.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-cargo-workspace.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-cargo.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-cocoapods.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-composer.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-cpp-conan.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-go-audit-high.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-go-audit.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-go-enrich.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-go-reachability.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-go.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-gradle-multimodule.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-gradle.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-java-maven-reachability.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-maven-multimodule.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-maven.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-mix.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-npm-audit.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-npm-reachability.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-npm-scope-runtime.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-npm-workspaces.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-npm.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-nuget.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-pnpm-workspaces.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-pnpm.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-recursive-monorepo.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-sbt.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-swiftpm.golden.json is excluded by !**/*.golden.json, !**/testdata/**
  • test/smoke/testdata/golden/scan-yarn.golden.json is excluded by !**/*.golden.json, !**/testdata/**
📒 Files selected for processing (43)
  • internal/detectors/attribution.go
  • internal/detectors/attribution_detectors_test.go
  • internal/detectors/attribution_test.go
  • 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/githubactions/detector.go
  • internal/detectors/gomod/attribution_test.go
  • internal/detectors/gomod/detector.go
  • internal/detectors/gradle/detector.go
  • internal/detectors/guards_test.go
  • internal/detectors/maven/attribution_test.go
  • internal/detectors/maven/detector.go
  • internal/detectors/mix/detector.go
  • internal/detectors/node/bun/bun_lockfile.go
  • internal/detectors/node/bun/bun_native.go
  • internal/detectors/node/npm/npm_lockfile.go
  • internal/detectors/node/npm/npm_lockfile_attribution_test.go
  • internal/detectors/node/npm/npm_lockfile_parser.go
  • internal/detectors/node/npm/npm_native.go
  • internal/detectors/node/pnpm/pnpm_lockfile.go
  • internal/detectors/node/pnpm/pnpm_native.go
  • internal/detectors/node/yarn/yarn_lockfile.go
  • internal/detectors/node/yarn/yarn_native.go
  • internal/detectors/nuget/detector.go
  • internal/detectors/pub/detector.go
  • internal/detectors/pub/pub_native.go
  • internal/detectors/python/pip.go
  • internal/detectors/python/pipenv.go
  • internal/detectors/python/poetry.go
  • internal/detectors/python/uv.go
  • internal/detectors/ruby/detector.go
  • internal/detectors/sbom/detector.go
  • internal/detectors/sbt/detector.go
  • internal/detectors/sbt/sbt_native.go
  • internal/detectors/swiftpm/detector.go
  • internal/detectors/swiftpm/swiftpm_native.go
  • internal/engine/consolidation/locations.go
  • internal/engine/consolidation/locations_test.go
  • internal/output/types.go
  • internal/output/types_test.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 6, 2026 •

Copy link
Copy Markdown
Contributor

Bomly Diff Summary

Compared 714068fdcf12b0606ba000b11ff937bf0fe3cfe1 to ba20cf61e5bf73b81f5deac3995e3b117fdef4d5.

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 27s

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.

bomly-guy and others added 2 commits September 6, 2026 12:56
…here

The producer half of this change records per-site attribution, but the
output projection dropped it -- so a workspace that reaches one lockfile
line from two members rendered the same path twice, byte-identical, with
nothing to tell the reader why. That is worse than the summary it
replaced, and it is why the two workspace smoke goldens were left red
rather than blessed as drift.

The projection carries the module root, the per-site scopes and the
per-site relationship now, so those records are distinguishable and a
conjunctive question -- reachable, runtime and direct -- can be asked of
one usage rather than of three different ones summarized onto a package.

An attributed site with no path is kept: it still says which module
reached the package and how.

Carrying a slice costs LocationRef its comparability, the same break the
SDK took on PackageLocation for the same reason. Nothing compares one or
keys a map by one; the type says so.

Goldens and schemas regenerated. Every removed golden line is a real_path
re-emitted with a trailing comma because fields now follow it -- checked,
not assumed.

Co-Authored-By: Claude Opus 5 <[email protected]>
Thirty-six goldens gain module_root, relationship and scopes on their
location records. Audited rather than blessed: across every one, the only
added keys are those three plus the paths of records that now appear
distinctly, and not a single removed line is anything but a real_path or
access_path re-emitted with a trailing comma because fields follow it.

The baseline workflow golden moved too, and that one was checked on its
own: finding ids, dependency refs, package identity and the audit summary
are untouched, so baselines still key on package and finding references
rather than on locations.

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

Copy link
Copy Markdown
Collaborator Author

Follow-up pushed: this PR is now self-contained and green.

It was opened deliberately red, with two workspace smoke goldens left unregenerated because the output projection dropped the new attribution — so a workspace reaching one lockfile line from two members rendered the same path twice, byte-identical. That was the right call: it is a regression, not drift, and blessing it would have shipped output worse than the summary it replaced.

Rather than leave a merge-ordering dependency, I carried the attribution into output.LocationRef here:

 "access_path": "package-lock.json",
+"module_root": "apps/web",
 "real_path": "package-lock.json",
+"relationship": "direct",
+"scopes": ["development"]

Those two records are now distinguishable, and a conjunctive question — reachable, runtime and direct — can be asked of one usage instead of three summarized onto a package.

Golden movement was wider than the two predicted: 36 goldens carry locations, including the audit, diff, explain and lite suites. Audited rather than accepted — across all 36 the only added keys are module_root, relationship, scopes and the paths of records that now appear distinctly, and not one removed line is anything but a real_path/access_path re-emitted with a trailing comma.

finding-baseline-workflow moved too, and I checked that one separately because it is the compatibility question the plan calls out: finding ids, dependency refs, package identity and the audit summary are all untouched. Baselines still key on package and finding references, not locations.

Schemas regenerated (scan, diff, explain). LocationRef loses comparability by carrying a slice — the same deliberate break the SDK took on PackageLocation; nothing compares one or keys a map by one, and the type now says so.

Mutations: dropping the attribution from the projection fails TestLocationRefsCarryTheAttributionThatDistinguishesThem; dropping an attributed site with no path fails TestAnAttributedSiteWithNoPathIsKept.

make verify SMOKE=1 green.

🤖 Generated with Claude Code

@bomly-guy
bomly-guy merged commit 1310051 into main Sep 9, 2026
16 checks passed
@bomly-guy
bomly-guy deleted the claude/phase-2.8a-detector-attribution branch September 9, 2026 07:01
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