dependency audit only scans root-level manifests #23

Closed
opened 2026-06-04 16:19:28 +00:00 by KofTwentyTwo · 2 comments
KofTwentyTwo commented 2026-06-04 16:19:28 +00:00 (Migrated from github.com)

Finding

Dependency resolution detects ecosystems only at the scan root and then reads only root-level manifests. Source enumeration is repo-wide, but dependency enumeration is not.

Evidence

  • docs/license-tool-design.md:56 describes dependency resolution by manifest presence.
  • internal/resolve/resolve.go:31 to internal/resolve/resolve.go:41 detects resolvers only at the provided root path.
  • internal/resolve/maven.go:71 to internal/resolve/maven.go:76 reads only <root>/pom.xml.
  • internal/resolve/npm.go:56 to internal/resolve/npm.go:64 reads only <root>/package.json and <root>/node_modules.
  • internal/resolve/gradle.go:56 to internal/resolve/gradle.go:60 scans only root Gradle manifests.

Reproduction

  1. Create a repository with supported source files and a nested package, Maven module, or Gradle subproject under a subdirectory.
  2. Run audit from the repository root.
  3. Observe that source files from the nested project are included, but dependency licenses for the nested manifest are absent.

Why It Matters

Common multi-module repositories can appear cleaner than they are. The audit reports repo-wide source status while silently omitting nested dependency surfaces.

Suggested Fix

Add manifest discovery as a first-class step that finds nested ecosystem roots. Feed each resolver explicit manifest roots, and add fixture tests for Maven modules, npm workspaces or nested packages, and Gradle subprojects.

## Finding Dependency resolution detects ecosystems only at the scan root and then reads only root-level manifests. Source enumeration is repo-wide, but dependency enumeration is not. ## Evidence - `docs/license-tool-design.md:56` describes dependency resolution by manifest presence. - `internal/resolve/resolve.go:31` to `internal/resolve/resolve.go:41` detects resolvers only at the provided root path. - `internal/resolve/maven.go:71` to `internal/resolve/maven.go:76` reads only `<root>/pom.xml`. - `internal/resolve/npm.go:56` to `internal/resolve/npm.go:64` reads only `<root>/package.json` and `<root>/node_modules`. - `internal/resolve/gradle.go:56` to `internal/resolve/gradle.go:60` scans only root Gradle manifests. ## Reproduction 1. Create a repository with supported source files and a nested package, Maven module, or Gradle subproject under a subdirectory. 2. Run audit from the repository root. 3. Observe that source files from the nested project are included, but dependency licenses for the nested manifest are absent. ## Why It Matters Common multi-module repositories can appear cleaner than they are. The audit reports repo-wide source status while silently omitting nested dependency surfaces. ## Suggested Fix Add manifest discovery as a first-class step that finds nested ecosystem roots. Feed each resolver explicit manifest roots, and add fixture tests for Maven modules, npm workspaces or nested packages, and Gradle subprojects.
KofTwentyTwo commented 2026-06-04 16:30:55 +00:00 (Migrated from github.com)

Status: confirmed — dependency resolution detects and reads manifests only at the scan root, while source enumeration is repo-wide; reproduced behaviorally.

Severity: high (label says bug/dependencies). The defect produces a silently incomplete audit — a multi-module repo reports its nested dependency surfaces as if they don't exist, with no warning. For a tool whose stated value is "an unresolved dependency is an honest gap, not a fabricated answer" (internal/resolve/resolve.go:9-14), silently omitting whole manifests is a correctness/trust failure, not a cosmetic one. Not critical only because it under-reports rather than mis-asserting a license.

Verified root cause: the asymmetry lives in the audit pipeline wiring, not in the resolvers themselves.

  • cmd/license-tool/commands.go:387-396 (buildAuditPipeline's ResolveDeps): iterates resolve.Detected(root) and calls r.Resolve(root, ...) against the single root path only — never recurses.
  • internal/resolve/resolve.go:34-42 (Detected): detects ecosystems at exactly one path.
  • Each resolver then reads only <path>/<manifest>: internal/resolve/maven.go:72 (filepath.Join(path, "pom.xml")), internal/resolve/npm.go:57,64 (<path>/package.json, <path>/node_modules), internal/resolve/gradle.go:58-60 (scanGradleManifest(filepath.Join(path, m), ...)).

Contrast with source enumeration, which is repo-wide: internal/enumerate/enumerate.go:80-91 lists files via gitListFiles / walkListFiles (the latter being filepath.WalkDir at enumerate.go:269). So the same nested pom.xml is read as a source file but never as a manifest.

Reproduction (built from current main): a repo with root pom.xml (com.root:rootdep) plus sub/module/pom.xml (com.nested:nesteddep) — audit . enumerated 4 source files including sub/module/mod.go and sub/module/pom.xml, but reported exactly 1 dependency (com.root:rootdep); the nested com.nested:nesteddep was silently absent.

Suggested fix: introduce a manifest-discovery step that walks the repo (reusing the enumeration walk / git ls-files, honoring the same .gitignore + --exclude semantics so the two surfaces stay symmetric) and yields every directory containing a known manifest, then run resolve.Detected(dir) + r.Resolve(dir, ...) per discovered root. The natural seam is ResolveDeps in cmd/license-tool/commands.go:387-396; the resolvers already take a per-manifest path and need no change. This must touch all three resolver detection paths consistently (Maven pom.xml, npm package.json, Gradle build.gradle[.kts]) — like the v0.2.1 universal-shebang fix, the discovery table should enumerate the manifest filenames in one place (mirror gradleManifests / the Detect predicates) rather than special-casing one ecosystem. Deduplicate so the root manifest isn't resolved twice. To keep the 100% gate green, add fixture tests for: a Maven reactor with a nested module, an npm nested package / workspace, and a Gradle subproject, asserting nested deps appear; plus a test that excluded/ignored directories are not descended into.

Corrections to the issue:

  • The design-doc citation is off by three lines. The dependency-resolution description is at docs/license-tool-design.md:59 (Data-flow step 4, "detect ecosystems by manifest presence"), not :56 (which is "Resolve config", step 1). Note also that the doc text itself does not promise recursion — so this is arguably an under-specification in the design doc as much as a code bug; a maintainer may want to clarify intended scope there too.
  • npm nuance: even within a single resolved root, npm resolution only reads the direct declared deps' installed package.json (npm.go:63,72), not the full transitive node_modules tree. The issue frames the gap purely as "nested manifest roots," but a workspaces repo also hides transitive/sibling-workspace packages. The fix scope should state whether transitive coverage is in or out.
  • Otherwise the evidence line refs (resolve.go:31-41, maven.go:71-76, npm.go:56-64, gradle.go:56-60) are all accurate.

Open questions:

  • Discovery scope: walk the whole repo for manifests, or only roots reachable from the top-level manifest's declared modules (<modules> in Maven, workspaces in npm, include in Gradle settings)? Whole-repo walk is simpler and matches source enumeration, but may surface vendored/example manifests.
  • Should discovery honor --exclude / .gitignore identically to source enumeration (recommended for symmetry), and should there be a flag to opt out / cap depth for huge monorepos?
  • De-dup / aggregation semantics: report nested deps grouped per-module, or flattened with de-duplication across modules?
  • Out of scope confirmation: is npm transitive-dependency coverage in scope for this fix, or strictly nested manifest roots?

Overlaps: none that duplicate this. #22 (Gradle tool tier advertised-but-ignored) and #25 (invalid resolver-tier values silently accepted) also touch internal/resolve/ but are independent defects; a fix here neither depends on nor resolves them. #11 (docs gap) is unrelated.

Automated maintainer review.

**Status**: confirmed — dependency resolution detects and reads manifests only at the scan root, while source enumeration is repo-wide; reproduced behaviorally. **Severity**: high (label says `bug`/`dependencies`). The defect produces a silently incomplete audit — a multi-module repo reports its nested dependency surfaces as if they don't exist, with no warning. For a tool whose stated value is "an unresolved dependency is an honest gap, not a fabricated answer" (`internal/resolve/resolve.go:9-14`), silently omitting whole manifests is a correctness/trust failure, not a cosmetic one. Not critical only because it under-reports rather than mis-asserting a license. **Verified root cause**: the asymmetry lives in the audit pipeline wiring, not in the resolvers themselves. - `cmd/license-tool/commands.go:387-396` (`buildAuditPipeline`'s `ResolveDeps`): iterates `resolve.Detected(root)` and calls `r.Resolve(root, ...)` against the single `root` path only — never recurses. - `internal/resolve/resolve.go:34-42` (`Detected`): detects ecosystems at exactly one `path`. - Each resolver then reads only `<path>/<manifest>`: `internal/resolve/maven.go:72` (`filepath.Join(path, "pom.xml")`), `internal/resolve/npm.go:57,64` (`<path>/package.json`, `<path>/node_modules`), `internal/resolve/gradle.go:58-60` (`scanGradleManifest(filepath.Join(path, m), ...)`). Contrast with source enumeration, which is repo-wide: `internal/enumerate/enumerate.go:80-91` lists files via `gitListFiles` / `walkListFiles` (the latter being `filepath.WalkDir` at `enumerate.go:269`). So the same nested `pom.xml` is read as a *source file* but never as a *manifest*. Reproduction (built from current `main`): a repo with root `pom.xml` (`com.root:rootdep`) plus `sub/module/pom.xml` (`com.nested:nesteddep`) — `audit .` enumerated 4 source files including `sub/module/mod.go` and `sub/module/pom.xml`, but reported exactly **1** dependency (`com.root:rootdep`); the nested `com.nested:nesteddep` was silently absent. **Suggested fix**: introduce a manifest-discovery step that walks the repo (reusing the enumeration walk / `git ls-files`, honoring the same `.gitignore` + `--exclude` semantics so the two surfaces stay symmetric) and yields every directory containing a known manifest, then run `resolve.Detected(dir)` + `r.Resolve(dir, ...)` per discovered root. The natural seam is `ResolveDeps` in `cmd/license-tool/commands.go:387-396`; the resolvers already take a per-manifest `path` and need no change. This must touch **all three** resolver detection paths consistently (Maven `pom.xml`, npm `package.json`, Gradle `build.gradle[.kts]`) — like the v0.2.1 universal-shebang fix, the discovery table should enumerate the manifest filenames in one place (mirror `gradleManifests` / the `Detect` predicates) rather than special-casing one ecosystem. Deduplicate so the root manifest isn't resolved twice. To keep the 100% gate green, add fixture tests for: a Maven reactor with a nested module, an npm nested package / workspace, and a Gradle subproject, asserting nested deps appear; plus a test that excluded/ignored directories are not descended into. **Corrections to the issue**: - The design-doc citation is off by three lines. The dependency-resolution description is at `docs/license-tool-design.md:59` (Data-flow step 4, "detect ecosystems by manifest presence"), not `:56` (which is "Resolve config", step 1). Note also that the doc text itself does not promise recursion — so this is arguably an under-specification in the design doc as much as a code bug; a maintainer may want to clarify intended scope there too. - npm nuance: even within a single resolved root, npm resolution only reads the *direct* declared deps' installed `package.json` (`npm.go:63,72`), not the full transitive `node_modules` tree. The issue frames the gap purely as "nested manifest roots," but a workspaces repo also hides transitive/sibling-workspace packages. The fix scope should state whether transitive coverage is in or out. - Otherwise the evidence line refs (`resolve.go:31-41`, `maven.go:71-76`, `npm.go:56-64`, `gradle.go:56-60`) are all accurate. **Open questions**: - Discovery scope: walk the *whole* repo for manifests, or only roots reachable from the top-level manifest's declared modules (`<modules>` in Maven, `workspaces` in npm, `include` in Gradle settings)? Whole-repo walk is simpler and matches source enumeration, but may surface vendored/example manifests. - Should discovery honor `--exclude` / `.gitignore` identically to source enumeration (recommended for symmetry), and should there be a flag to opt out / cap depth for huge monorepos? - De-dup / aggregation semantics: report nested deps grouped per-module, or flattened with de-duplication across modules? - Out of scope confirmation: is npm transitive-dependency coverage in scope for this fix, or strictly nested manifest roots? **Overlaps**: none that duplicate this. #22 (Gradle tool tier advertised-but-ignored) and #25 (invalid resolver-tier values silently accepted) also touch `internal/resolve/` but are independent defects; a fix here neither depends on nor resolves them. #11 (docs gap) is unrelated. _Automated maintainer review._
KofTwentyTwo commented 2026-06-04 20:28:19 +00:00 (Migrated from github.com)

Resolved on develop in commit 742c124e6ec2afa9312075db3112bc2def3c91ff.

Validation:

Closing as fixed.

Resolved on `develop` in commit `742c124e6ec2afa9312075db3112bc2def3c91ff`. Validation: - CI: success, https://github.com/KofTwentyTwo/license-tool/actions/runs/26977377097 - CodeQL: success, https://github.com/KofTwentyTwo/license-tool/actions/runs/26977376421 - Coverage gate: 100% (2087/2087) Closing as fixed.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: KofTwentyTwo/license-tool#23