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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/depsnort/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1412,6 +1412,10 @@ func cmdScan(args []string) int {
&rubygems.WalkSource{Deps: gemDeps, Index: gemIdx},
&composer.WalkSource{Deps: composerDeps, Index: composerIdx},
&gomod.WalkSource{Proxy: goProxy},
// Identity-only (D-164): names deps.dev-asserted maven children
// with the adapter's namespace-form PURLs so observed-beats-
// asserted dedupe holds; its presume tier reads nothing, disclosed.
&clojure.WalkSource{},
}
// The asserted tier is default-on (OPU-12 D-2): a verdict presented as
// authoritative should rest on resolved facts, not this tool's presumed
Expand Down
39 changes: 39 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6848,3 +6848,42 @@ Residual limitations: one page of 200 versions bounds the OLD end of a very long
history every temporal check reads stays intact); pagination is the follow-up, not silently assumed away.
Clojars as above. And the first scan from a network that reaches both `api.osv.dev` and
`search.maven.org` remains the outstanding live confirmation for the whole D-162/D-163 chain.

## D-164 — one coordinate, one PURL: the maven identity seam for the asserted tier

**Trigger:** the elastic-agent live validation flagged a Maven purl-separator inconsistency at the asserted
tier's merge fallback, left open from D-162/D-163. Verified against the source before any fix: when
`mergeResolved` merges a deps.dev-resolved tree and no Declarer seam exists for the node's ecosystem, it
falls back to `"pkg:" + eco + "/" + name + "@" + version` — for a maven coordinate that keeps the colon in
the name segment (`pkg:maven/org.postgresql:postgresql@42.7.4`) while the adapter's observed nodes carry
the namespace form (`pkg:maven/org.postgresql/postgresql@42.7.4`). Two IDs for one package: the
observed-beats-asserted dedupe at `g.Get` cannot match, so the same direct dependency re-enters the graph
as a colon-form twin with advisories and registry data split across the pair. This is the D-15 identity
leak, one tier up — and the Declarer contract's own doc names Identify as where the prevention lives.
A second consequence compounded it: the walk skips ecosystems without a declarer as expansion candidates,
so D-162's "the -expand tier can deepen Clojure trees" held only through the asserted tier, whose merged
children then carried the malformed IDs.

**The fix is an identity-only Declarer.** `clojure.WalkSource` implements the seam for ecosystem `maven`:
Identify splits `group:artifact` into the adapter's own `purl.NewMaven` namespace form (a bare name maps
group == artifact, the Leiningen convention), and Declared is deliberately honest-empty — reading a Maven
package's declared dependencies means fetching and interpreting poms (parents, properties,
dependencyManagement), the pom.xml problem, its own decision. The walk's contract counts a coordinate
absent from the returned map as "not read", which is the true state, so presume-tier coverage over maven
discloses as unread rather than being guessed; real transitive resolution stays with the asserted tier,
whose children this seam now names correctly.

**Validation:** the seam's Identify pinned against the adapter's emitted node for the same manifest (one
coordinate, one PURL, whichever tier names it first); an asserted-merge test through the real Walker and a
deps.dev-shaped stub proving the queried dep dedupes against the observed node, the asserted child lands at
the namespace-form PURL with asserted truth, and no colon-form ID exists; and the mutation half encoded
permanently — the same scenario run WITHOUT the seam reproduces the split (with a skip-and-revisit note
should the engine's raw fallback ever learn purl grammar). Full suite green (35 packages), `-race` clean,
gofmt/vet silent.

Residual limitations: the registration is one literal in the -expand source list, built inline among a
dozen clients — unlike D-163's registry list there is no cheap seam to pin it from a test, so its coverage
is the encoded contrast plus this entry, and extracting the construction for a registration pin is noted
as the refactor it would be rather than smuggled into a bug fix. Declared stays empty until pom reading is
its own decision. And the engine's raw fallback still writes `pkg:eco/name@version` for any FUTURE
ecosystem that gains asserted resolution before its identity seam — the pattern to copy is this one.
26 changes: 26 additions & 0 deletions internal/ecosystem/clojure/clojure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,29 @@ func ids(g *graph.Graph) []string {
}
return out
}

// D-164: the walk seam's identity must equal the adapter's own node identity —
// one coordinate, one PURL, whichever tier names it first.
func TestWalkSourceIdentifyMatchesAdapterIdentity(t *testing.T) {
ws := &WalkSource{}
cases := map[string][2]string{
"org.postgresql:postgresql": {"pkg:maven/org.postgresql/postgresql@42.7.4", "org.postgresql:postgresql"},
"postgresql": {"pkg:maven/postgresql/postgresql@42.7.4", "postgresql:postgresql"},
}
for in, want := range cases {
id, canon := ws.Identify(in, "42.7.4")
if id != want[0] || canon != want[1] {
t.Errorf("Identify(%q) = (%q,%q), want (%q,%q)", in, id, canon, want[0], want[1])
}
}
if id, _ := ws.Identify("", "1"); id != "" {
t.Errorf("empty name must yield no identity, got %q", id)
}
// And it must agree with what Resolve emits for the same manifest.
g := resolve(t, writeManifest(t, "project.clj",
`(defproject x "1" :dependencies [[org.postgresql/postgresql "42.7.4"]])`))
id, _ := ws.Identify("org.postgresql:postgresql", "42.7.4")
if g.Nodes[id] == nil {
t.Errorf("seam identity %q does not match the adapter's emitted node", id)
}
}
57 changes: 57 additions & 0 deletions internal/ecosystem/clojure/walk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package clojure

import (
"context"
"strings"

"ihbv.io/depsnort/internal/datasource"
"ihbv.io/depsnort/internal/expand"
"ihbv.io/depsnort/internal/purl"
)

// WalkSource is the maven-coordinate side of the Nth-layer walk — an
// IDENTITY-ONLY seam (D-164). Its load-bearing half is Identify: before it
// existed, a deps.dev-asserted maven child took the engine's raw fallback ID
// (`pkg:maven/group:artifact@v`, colon kept in the name) while the adapter's
// observed nodes carry the namespace form (`pkg:maven/group/artifact@v`) —
// two IDs for one package, so "observed beats asserted" failed to match and
// the same dependency could enter the graph twice with its advisories split
// across the twins. That is the D-15 identity leak, one tier up, and exactly
// what the Declarer contract says Identify exists to prevent.
//
// Declared is deliberately honest-empty: reading a Maven package's own
// declared dependencies means fetching and interpreting poms (parents,
// properties, dependencyManagement) — the pom.xml problem, its own decision.
// The walk's contract counts a coordinate ABSENT from the map as "not read",
// which is the true state, so presume-tier coverage over maven discloses as
// unread rather than being guessed. Real transitive resolution stays with the
// asserted tier (deps.dev), whose merged children this seam now names
// correctly.
type WalkSource struct{}

// Ecosystem implements expand.Declarer. Matches graph.Node.Ecosystem — the
// coordinate space, "maven", not the manifest family (see the package doc).
func (*WalkSource) Ecosystem() string { return "maven" }

// Identify normalizes a Maven coordinate to the adapter's own identity:
// `group:artifact` splits into the pkg:maven namespace form, and a bare name
// maps group == artifact (the Leiningen convention the adapter established).
// Coordinates are case-sensitive; nothing is folded.
func (*WalkSource) Identify(name, version string) (id, canonical string) {
name = strings.TrimSpace(name)
if name == "" {
return "", ""
}
group, artifact := name, name
if i := strings.IndexByte(name, ':'); i > 0 && i < len(name)-1 {
group, artifact = name[:i], name[i+1:]
}
return purl.NewMaven(group, artifact, version).String(), group + ":" + artifact
}

// Declared implements expand.Declarer: nothing is read (see the type comment).
// Every requested coordinate is absent from the returned map, which the walk
// counts as unread — disclosed, never presumed.
func (*WalkSource) Declared(context.Context, []datasource.Coord) (map[string][]expand.Declaration, error) {
return map[string][]expand.Declaration{}, nil
}
102 changes: 102 additions & 0 deletions internal/expand/d164_maven_identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package expand_test

import (
"context"
"testing"

"ihbv.io/depsnort/internal/ecosystem/clojure"
"ihbv.io/depsnort/internal/expand"
"ihbv.io/depsnort/internal/graph"
)

// d164Resolver returns one canned resolved graph for the postgresql direct
// dep, the shape deps.dev hands the asserted tier for a maven coordinate.
type d164Resolver struct{ calls []string }

func (*d164Resolver) Name() string { return "d164-stub" }

func (r *d164Resolver) Resolve(_ context.Context, eco, name, version string) (expand.ResolvedGraph, bool, error) {
r.calls = append(r.calls, eco+"|"+name+"|"+version)
if eco != "maven" || name != "org.postgresql:postgresql" {
return expand.ResolvedGraph{}, false, nil
}
return expand.ResolvedGraph{
Nodes: []expand.ResolvedRef{
{Ecosystem: "maven", Name: "org.postgresql:postgresql", Version: "42.7.4"}, // index 0: the queried dep
{Ecosystem: "maven", Name: "org.checkerframework:checker-qual", Version: "3.42.0"},
},
Edges: []expand.ResolvedEdge{{From: 0, To: 1}},
}, true, nil
}

// mavenScanGraph builds what the clojure adapter actually emits for the jepsen
// shape: a path-source root plus the OBSERVED direct dep at the adapter's
// namespace-form PURL.
func mavenScanGraph() (*graph.Graph, *graph.Node) {
g := graph.New()
root := g.AddNode(&graph.Node{
ID: "pkg:maven/swytch.jepsen@0.1.0", Kind: graph.KindPackage, Ecosystem: "maven",
Name: "swytch.jepsen", Version: "0.1.0",
Attr: map[string]string{
graph.AttrDeclaredDeps: graph.EncodeDeclaredDeps([]graph.DeclaredDep{
{Name: "org.postgresql:postgresql", Constraint: "42.7.4"},
}),
},
})
root.SetSource(graph.SourcePath, "")
g.MarkRoot(root.ID)
observed := g.AddNode(&graph.Node{
ID: "pkg:maven/org.postgresql/postgresql@42.7.4", Kind: graph.KindPackage,
Ecosystem: "maven", Name: "org.postgresql:postgresql", Version: "42.7.4",
Direct: true, Depth: 1,
Attr: map[string]string{graph.AttrSourceClass: graph.SourceRegistry},
})
g.AddEdge(root.ID, observed.ID, graph.EdgeDependsOn)
return g, root
}

// D-164: with the maven Declarer seam, a deps.dev-asserted maven tree merges
// under the adapter's namespace-form PURLs — the queried dep dedupes against
// the observed node instead of re-entering under a colon-form twin, and the
// asserted child is named the same way the adapter would name it.
func TestAssertedMavenTreeSharesObservedIdentity(t *testing.T) {
g, root := mavenScanGraph()
_, err := expand.NewWalker(&clojure.WalkSource{}).
ExpandRoot(context.Background(), g, root, expand.Options{Resolver: &d164Resolver{}})
if err != nil {
t.Fatal(err)
}

if twin := g.Get("pkg:maven/org.postgresql:postgresql@42.7.4"); twin != nil {
t.Error("colon-form twin of the observed dep exists: observed-beats-asserted dedupe failed")
}
child := g.Get("pkg:maven/org.checkerframework/checker-qual@3.42.0")
if child == nil {
t.Fatal("asserted child missing at the namespace-form PURL")
}
if child.VersionTruth() != graph.TruthAsserted {
t.Errorf("child truth = %q, want asserted", child.VersionTruth())
}
if g.Get("pkg:maven/org.checkerframework:checker-qual@3.42.0") != nil {
t.Error("asserted child also present at the colon-form ID: identity split")
}
}

// The permanently-encoded mutation half: WITHOUT the seam, the engine's raw
// fallback keeps the colon in the name segment and the identity splits — the
// D-15 leak shape, one tier up. This is the live bug the elastic-agent
// validation flagged at asserted.go's fallback; if the engine's fallback is
// ever taught purl grammar generally, this assertion is the one to revisit.
func TestAssertedMavenTreeSplitsWithoutTheSeam(t *testing.T) {
g, root := mavenScanGraph()
_, err := expand.NewWalker( /* no maven declarer */ ).
ExpandRoot(context.Background(), g, root, expand.Options{Resolver: &d164Resolver{}})
if err != nil {
t.Fatal(err)
}
colonTwin := g.Get("pkg:maven/org.postgresql:postgresql@42.7.4")
colonChild := g.Get("pkg:maven/org.checkerframework:checker-qual@3.42.0")
if colonTwin == nil && colonChild == nil {
t.Skip("the raw fallback no longer produces colon-form maven IDs; the seam may be redundant — revisit D-164")
}
}
Loading