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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ jobs:
fuzz ./internal/ecosystem/composer FuzzParseComposerLock
fuzz ./internal/ecosystem/nuget FuzzParsePackagesLock
fuzz ./internal/ecosystem/pypi FuzzExtractFromTar
fuzz ./internal/ecosystem/clojure FuzzParseProjectClj
fuzz ./internal/ecosystem/clojure FuzzParseDepsEdn

bench:
name: benchmarks compile and run
Expand Down
37 changes: 35 additions & 2 deletions cmd/depsnort/d161_zero_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ package main
import (
"os"
"path/filepath"
"strings"
"testing"
)

Expand All @@ -21,18 +22,50 @@ const leinProject = `(defproject swytch.jepsen "0.1.0"
[org.postgresql/postgresql "42.7.4"]])
`

func TestClojureManifestIsIncompleteCoverageNotCleanPass(t *testing.T) {
// D-162 superseded the D-161 shape this test originally pinned: project.clj
// is now CLAIMED and RESOLVED by the clojure adapter, so the jepsen fixture
// scans as a real project (fully pinned, flat-by-format) instead of a gap.
// The D-161 gap behavior itself is still pinned below on pom.xml, a manifest
// that remains recognized-but-unread.
func TestClojureManifestNowResolves(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "project.clj"), []byte(leinProject), 0o644); err != nil {
t.Fatal(err)
}
out := filepath.Join(t.TempDir(), "out.json")
if code := run([]string{"scan", "-no-osv", "-no-registry", "-out", out, dir}); code != 0 {
t.Errorf("scan of a pinned project.clj repo: exit = %d, want 0", code)
}
// Not just a clean exit: the manifest must actually RESOLVE — the pinned
// JDBC driver present as a maven node. Without this the test would pass
// vacuously if the adapter were unregistered (nothing-to-scan is also 0).
raw, err := os.ReadFile(out)
if err != nil {
t.Fatalf("no verdict written — project.clj was not scanned: %v", err)
}
if !strings.Contains(string(raw), "pkg:maven/org.postgresql/postgresql@42.7.4") {
t.Error("resolved graph must contain the pinned postgresql coordinate")
}
// Fully pinned direct deps: nothing unresolved, and flat resolution is a
// format limitation (the Pipfile.lock precedent, D-24) — it discloses, it
// does not gate.
if code := run([]string{"scan", "-no-osv", "-no-registry", "-fail-on-incomplete", dir}); code != 0 {
t.Errorf("-fail-on-incomplete on a fully-pinned project.clj: exit = %d, want 0", code)
}
}

func TestUnreadManifestIsIncompleteCoverageNotCleanPass(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "pom.xml"), []byte("<project><groupId>x</groupId></project>\n"), 0o644); err != nil {
t.Fatal(err)
}
// Without the gate: disclosed, advisory-tier, still exit 0.
if code := run([]string{"scan", "-no-osv", "-no-registry", dir}); code != 0 {
t.Errorf("ungated scan of a gap-only repo: exit = %d, want 0 (disclosure, not a gate)", code)
}
// With the gate: the recognized-but-unread manifest is degraded coverage.
if code := run([]string{"scan", "-no-osv", "-no-registry", "-fail-on-incomplete", dir}); code != 3 {
t.Errorf("-fail-on-incomplete on a project.clj repo: exit = %d, want 3 (zero-coverage repos must not pass the coverage gate)", code)
t.Errorf("-fail-on-incomplete on a pom.xml repo: exit = %d, want 3 (zero-coverage repos must not pass the coverage gate)", code)
}
}

Expand Down
12 changes: 4 additions & 8 deletions cmd/depsnort/gap.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,10 @@ var gapManifestByName = map[string]string{
"pubspec.yaml": "dart",
"Podfile": "cocoapods",
"Package.swift": "swift",
// D-161: the swytchdb live scan walked a Leiningen project whose project.clj
// declared a JDBC driver carrying three real advisories, and exited 0 with
// "nothing to scan" — these two names were simply missing from this table
// while the D-59 machinery for them already existed. .clj is any Clojure
// source and .edn any EDN data, so both are exact-name per the dedication
// rule above.
"project.clj": "leiningen", // Clojure/Leiningen; resolves from Clojars + Maven Central
"deps.edn": "clojure", // Clojure tools.deps
// project.clj / deps.edn entered this table at D-161 and were promoted out
// at D-162: the clojure adapter now claims and RESOLVES them, so per the
// supported-manifests rule at the top of this table they must not also be
// listed here (a dependency-less one is legitimately empty, not a gap).
// go.work is deliberately omitted: it is a workspace aggregator whose local
// `use` modules are each scanned on their own, so disclosing the workspace
// file as an unread gap would be a spurious note on an already-covered repo —
Expand Down
6 changes: 2 additions & 4 deletions cmd/depsnort/gap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,6 @@ func TestClassifyGapManifest(t *testing.T) {
"flake.lock": "nix",
"conan.lock": "conan",
"deno.lock": "deno",
// D-161 — Clojure manifests, missed live on a Leiningen project whose
// JDBC driver carried three real advisories.
"project.clj": "leiningen",
"deps.edn": "clojure",
}
for name, wantEco := range gaps {
if eco, ok := classifyGapManifest(name); !ok || eco != wantEco {
Expand All @@ -57,6 +53,8 @@ func TestClassifyGapManifest(t *testing.T) {
for _, name := range []string{
"package.json", "requirements.txt", "pyproject.toml", "composer.json", "go.mod",
"packages.lock.json", "packages.config", "Gemfile", "README.md",
// Promoted out at D-162: the clojure adapter claims these.
"project.clj", "deps.edn",
// Adapter-handled .lock files must be excluded from the hail-mary catch-all —
// their directories are claimed and scanned, not disclosed as unknown gaps.
"Cargo.lock", "composer.lock", "yarn.lock", "Gemfile.lock", "Pipfile.lock", "paket.lock",
Expand Down
2 changes: 2 additions & 0 deletions cmd/depsnort/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import (
"ihbv.io/depsnort/internal/datasource/registry"
"ihbv.io/depsnort/internal/ecosystem"
"ihbv.io/depsnort/internal/ecosystem/cargo"
"ihbv.io/depsnort/internal/ecosystem/clojure"
"ihbv.io/depsnort/internal/ecosystem/composer"
"ihbv.io/depsnort/internal/ecosystem/gomod"
"ihbv.io/depsnort/internal/ecosystem/instsurf"
Expand Down Expand Up @@ -288,6 +289,7 @@ func adapterRegistry(offline bool, scanRoot ...string) *ecosystem.Registry {
composer.New(),
nuget.New(),
gomod.New(),
clojure.New(),
)
}

Expand Down
72 changes: 72 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6727,3 +6727,75 @@ ask the run's report itself judged it. Actually PARSING Maven/Clojars manifests
dependencies against OSV's strong Maven data) remains ecosystem work of a different size, tracked as a
backlog item, not smuggled in here. And `-require-project` asserts only that at least one project or
recognized gap was discovered — it does not (and should not) judge how many, or which.

## D-162 — a Clojure adapter: project.clj and deps.edn resolve to Maven coordinates

**Trigger:** the swytchdb live scan left two of four repos unscanned, and D-161 closed only the honesty
half — `swytch.jepsen`'s `project.clj` went from a silent clean pass to a disclosed gap, but its JDBC
driver's three real advisories stayed invisible. D-161's own residual named the remaining work: actually
resolving the declared dependencies. This is that increment, scoped deliberately to the Clojure manifest
family (Leiningen `project.clj`, tools.deps `deps.edn`) — the demonstrated live miss — with `pom.xml` left
a disclosed gap and Homebrew untouched.

**The identity decision that shapes everything else: nodes carry Ecosystem `maven`, not `clojure`.** The
manifest family is Clojure, but the packages live at Maven coordinates in Maven Central and Clojars, and
every downstream authority speaks Maven: OSV's ecosystem is "Maven" with `group:artifact` names, deps.dev's
system is `maven` with the same form, the PURL type is `pkg:maven`. The adapter's Name() is "clojure" (what
it reads); its nodes say where the packages actually live (what they are). A future pom.xml or Gradle
adapter emits into the same coordinate space and every mapping added here serves it unchanged. One switch
case each in the OSV client ("maven" → "Maven" — the default pass-through would have silently returned
zero advisories, the exact failure the mapping's own comment warns about) and in deps.dev (both
directions), and the existing `-expand` tier can deepen a Clojure tree from deps.dev with no new code.

**What the adapter claims, and what it refuses to claim (D-24).** A direct dependency with a literal
version IS an observed pin — Leiningen and tools.deps fetch exactly the stated version for direct deps —
so `[org.postgresql/postgresql "42.7.4"]` enters the graph as fact, `AttrSourceClass` registry. Everything
short of that literal is disclosed, never guessed: a range (`"[1.0,2.0)"`), `RELEASE`/`LATEST`, or a
build-time symbol version is declared-but-unresolved; a `:git/url` or `:local/root` coordinate carries
`SourceGit`/`SourcePath` with its ref — no registry coordinate, no advisory coverage, counted like any
other non-registry source; an entry the reader cannot parse at all becomes a placeholder in
`AttrUnresolved` (a shape we cannot name degrades coverage; it does not vanish). Neither format records
the transitive closure, so every root sets `AttrFlatResolution` — the Pipfile.lock precedent: a limitation
of the format disclosed, not a scan defect, and not a gate. Profile `:dependencies` and alias
`:extra-deps`/`:replace-deps` are read — a dev-profile dependency is fetched from the same registries and
is the same surface. Lein bare symbols map per Leiningen's own convention (`[postgresql "42.7.4"]` is
`postgresql:postgresql`).

**The reader is a scanner, not an EDN parser.** depSNORT is zero-dependency, and these manifests need
three structural facts: where a `;` comment ends (respecting strings and `\;` character literals), where a
string literal ends, and where a balanced form ends. `#_` reader-discards are skipped as the
commented-out declarations they are; a coordinate symbol must match the Maven id shape or the entry is
unparsed rather than a mangled token entering the graph; a dependency spelled inside a comment or a
docstring never becomes a node (the D-153 lesson, applied at parse time rather than patched in later).

**Promotion out of the gap tables.** `project.clj` and `deps.edn` leave `gapManifestByName` per that
table's own contract — a supported manifest is claimed by Detect and must not also be listed, or a
legitimately dependency-less one would read as a gap. Detect claims only a manifest that declares
something (the Gemfile bar, OPU-16). D-161's CLI regression is superseded knowingly: the jepsen fixture
now RESOLVES (asserted non-vacuously — the verdict must contain the postgresql PURL, so an unregistered
adapter cannot pass on exit codes alone), a fully-pinned project.clj passes `-fail-on-incomplete` (flat
discloses, nothing gates), and the D-161 gap behavior itself stays pinned on `pom.xml`, which remains
recognized-but-unread.

**Validation:** two-sided unit tests (pins resolve with correct PURLs/coordinates; ranges, meta-versions,
symbol versions, and unparsed shapes disclose; discarded and comment/string-embedded "dependencies" never
resolve; git/local classify with refs); two fuzz targets over the untrusted-input readers
(`FuzzParseProjectClj`, `FuzzParseDepsEdn` — never panic, never stall, no coordinate escapes the symbol
shape), seeded, run 20s locally and added to the CI fuzz roster; mutation-checked at both load-bearing
wires (reverting the OSV mapping fails the wire-format test; reverting the registration fails the CLI
resolution test); full suite green (35 packages), `-race` clean, gofmt/vet silent. Live-fired through the
built binary on the jepsen shape: discovered, resolved to four `pkg:maven` nodes, and — with `api.osv.dev`
egress-blocked in the landing environment — the scan disclosed `degraded data source(s): osv … NOT an
all-clear` rather than passing quietly, which is itself the D-24 machinery working for the new ecosystem.
The OSV round-trip that egress denied is pinned at the wire instead (`maven_test.go`): the request body
must carry `"ecosystem":"Maven"` and the verbatim `group:artifact` name.

Residual limitations: transitive coverage exists only through the `-expand` tier (asserted/presumed,
labelled, never gating) — there is no committed lockfile format to read. No Maven Central/Clojars registry
metadata source exists yet, so VC-004/VC-005/VC-011/VC-012 do not fire on maven nodes — the same shape as
any ecosystem before its registry source landed, and the natural next increment. `:plugins` and
`:managed-dependencies` are deliberately unread (a Leiningen-process surface and a version-authority
question, each its own decision), as are `pom.xml` (still a disclosed gap; property interpolation and
parent chains are real work, not a name-table entry) and Homebrew formulae (unchanged from D-161's
reasoning). And Maven version-range resolution is not implemented anywhere in this tool — a range is
disclosed as unresolved, never evaluated.
7 changes: 7 additions & 0 deletions internal/datasource/depsdev/depsdev.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ func system(ecosystem string) string {
return "nuget"
case "gem":
return "rubygems"
case "maven":
// Maven-coordinate nodes (the clojure adapter's project.clj /
// deps.edn pins, D-162): deps.dev's maven system uses the same
// "group:artifact" name form the nodes already carry.
return "maven"
default:
return ""
}
Expand Down Expand Up @@ -195,6 +200,8 @@ func ecosystemOf(system string) string {
return "nuget"
case "RUBYGEMS":
return "gem"
case "MAVEN":
return "maven"
default:
return lower(system)
}
Expand Down
49 changes: 49 additions & 0 deletions internal/datasource/osv/maven_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package osv

import (
"context"
"strings"
"testing"
"time"

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

// D-162: a maven-coordinate node (the clojure adapter's project.clj /
// deps.edn pins) must reach OSV as ecosystem "Maven" with the group:artifact
// name intact — OSV's documented Maven spelling. Passing the internal id
// through unmapped would silently return zero advisories, which on the
// swytch.jepsen shape means three real postgresql advisories invisible again.
// The live api.osv.dev round-trip was egress-blocked in the environment this
// landed from, so the wire format is pinned here instead.
func TestMavenCoordReachesOSVAsMaven(t *testing.T) {
resp := `{"results":[
{"vulns":[{"id":"GHSA-hq9p-pm7w-8p54","modified":"2026-01-01T00:00:00Z"}]}
]}`
doer := &fakeDoer{body: resp}
fixed := time.Date(2026, 8, 28, 0, 0, 0, 0, time.UTC)
cache := datasource.NewCache(t.TempDir(), 24*time.Hour)
cache.Now = func() time.Time { return fixed }
c := &Client{
HTTP: doer,
Cache: cache,
Endpoint: "http://test.invalid",
Now: func() time.Time { return fixed },
}

got, err := c.QueryBatch(context.Background(), []datasource.Coord{
{Ecosystem: "maven", Name: "org.postgresql:postgresql", Version: "42.7.4"},
})
if err != nil {
t.Fatalf("QueryBatch: %v", err)
}
if !strings.Contains(doer.lastBody, `"ecosystem":"Maven"`) {
t.Errorf("request must carry OSV's Maven spelling, got body: %s", doer.lastBody)
}
if !strings.Contains(doer.lastBody, `"name":"org.postgresql:postgresql"`) {
t.Errorf("request must carry the group:artifact coordinate verbatim, got body: %s", doer.lastBody)
}
if len(got) != 1 || len(got[0]) != 1 || got[0][0].ID != "GHSA-hq9p-pm7w-8p54" {
t.Errorf("advisory must round-trip onto the maven coord, got %+v", got)
}
}
5 changes: 5 additions & 0 deletions internal/datasource/osv/osv.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ func ecosystemName(eco string) string {
return "NuGet"
case "gomod":
return "Go"
case "maven":
// Maven coordinates regardless of manifest family: the clojure
// adapter's project.clj / deps.edn nodes resolve here (D-162). Node
// names are already "group:artifact", OSV's Maven package spelling.
return "Maven"
default:
return eco
}
Expand Down
Loading
Loading