From 32cba1f46330a02402f8853895e7c79e0c07bcbe Mon Sep 17 00:00:00 2001 From: spatten Date: Wed, 2 Sep 2026 16:59:41 -0700 Subject: [PATCH 1/2] Report declared deps as direct in Maven static analysis The pom tactic builds its graph rooted at the project itself, so the project was the only direct dependency and everything it declared was reported as transitive. In a multi-module project the root pom and every submodule were reported as dependencies too. The dynamic tactics already avoid this: Plugin.buildGraph marks the project's own packages direct and shrinkRoots removes them. Static analysis never got the equivalent step, and the TODO marking it as unfinished was dropped in #1331 a week before #1339 added shrinkRoots to the dynamic path. Take the same two steps here. Both are needed: shrinkRoots alone would remove only the root pom and promote the submodules to direct, and a shrink alone would leave a submodule-filtered graph with no direct dependencies at all, because submodule filtering deletes the root pom node without rewiring the edges through it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QrDtxeRNysKQz4NWXixRzU --- Changelog.md | 1 + .../strategies/languages/maven/pomxml.md | 11 ++ src/Strategy/Maven.hs | 31 ++- test/Maven/PomStrategySpec.hs | 183 ++++++++++++++++++ 4 files changed, 223 insertions(+), 3 deletions(-) diff --git a/Changelog.md b/Changelog.md index 08a06be75d..da8c458b75 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,6 +2,7 @@ ## Unreleased +- Maven: Static analysis of `pom.xml` files now reports the dependencies a project declares as direct. Previously the project itself was reported as the only direct dependency and everything it declared was reported as transitive, and in a multi-module project the root pom and every submodule were also reported as dependencies. This affects analysis without `mvn` on `PATH`, `--static-only-analysis`, and container scanning; analysis using `mvn` was already correct. Selecting a submodule with `--only-target` now reports that submodule's dependencies as direct instead of reporting none at all. - Diagnostics: When an error or warning group contains multiple errors, each error's `Traceback:` header is now printed on its own line instead of being glued onto the last line of the preceding error message (e.g. `...none passed validationTraceback:`). ([#1758](https://github.com/fossas/fossa-cli/pull/1758)) ## 3.18.2 diff --git a/docs/references/strategies/languages/maven/pomxml.md b/docs/references/strategies/languages/maven/pomxml.md index 2dedd52a84..b4b3420750 100644 --- a/docs/references/strategies/languages/maven/pomxml.md +++ b/docs/references/strategies/languages/maven/pomxml.md @@ -16,4 +16,15 @@ poms are linked together by their `` references into multi-project proje Each project in the single- or multi-project structure has its pom information overlayed on top of parent poms, and a dependency graph is gathered from each project. +The root pom and every submodule are excluded from the reported graph, and the +dependencies they declare are reported as direct. This is the same treatment the +`mavenplugin` and `treecmd` tactics give them: those are the user's own projects, +not things they depend on. A pom file only declares direct dependencies, so this +tactic reports no transitive dependencies and no edges. + +For example, given a project `com.example:root` whose `mod-a` submodule declares +`junit:junit` and whose `mod-b` submodule declares `com.google.guava:guava`, the +reported dependencies are `junit:junit` and `com.google.guava:guava`, both direct. +Neither `com.example:root` nor either submodule is reported. + We have limited support for naive `${property}` interpolation. diff --git a/src/Strategy/Maven.hs b/src/Strategy/Maven.hs index 8f5bef2478..5ae0edc6a6 100644 --- a/src/Strategy/Maven.hs +++ b/src/Strategy/Maven.hs @@ -3,6 +3,7 @@ module Strategy.Maven ( mkProject, MavenProject (..), getDeps, + getDepsStatically, ) where import App.Fossa.Analyze.LicenseAnalyze (LicenseAnalyzeProject, licenseAnalyzeProject) @@ -18,14 +19,14 @@ import Data.Set (Set) import Data.Set qualified as Set import Data.Set.NonEmpty (nonEmpty, toSet) import Data.Text hiding (group, map) -import DepTypes (Dependency) +import DepTypes (Dependency (dependencyName)) import Diag.Common (MissingDeepDeps (MissingDeepDeps), MissingEdges (MissingEdges)) import Discovery.Filters (AllFilters, MavenScopeFilters, mavenScopeFilterSet) import Discovery.Simple (simpleDiscover) import Effect.Exec (CandidateCommandEffs, GetDepsEffs) import Effect.ReadFS (ReadFS) import GHC.Generics (Generic) -import Graphing (Graphing, gmap, shrinkRoots) +import Graphing (Graphing, gmap, promoteToDirect, shrink, shrinkRoots) import Path (Abs, Dir, Path, parent) import Strategy.Maven.Common (MavenDependency (..), filterMavenDependencyByScope, filterMavenSubmodules, mavenDependencyToDependency) import Strategy.Maven.DepTree qualified as DepTreeCmd @@ -188,7 +189,31 @@ getStaticAnalysis submoduleTargets closure = do let allSubmodules = PomClosure.closureSubmodules closure (graph, graphBreadth) <- context "Static analysis" $ pure (Pom.analyze' closure, Partial) filteredGraph <- applyMavenFilters submoduleTargets allSubmodules graph - pure (filteredGraph, graphBreadth) + pure (withoutProjectsAsDeps allSubmodules filteredGraph, graphBreadth) + +-- | Remove the user's own packages -- the root pom and every submodule -- from a +-- static graph, promoting the dependencies they declare to direct. +-- +-- 'Pom.analyze'' builds a graph rooted at the project itself, so without this the +-- project is the only direct dependency and every dependency it declares is +-- reported as transitive. Marking the project packages direct and then shrinking +-- them away is the same two steps the dynamic tactics take, where +-- 'Plugin.buildGraph' marks them and 'shrinkRoots' removes them. +-- +-- Both steps are needed. 'shrinkRoots' alone would remove only the root pom and +-- promote the submodules to direct dependencies rather than dropping them. A +-- 'shrink' alone would leave a submodule-filtered graph with no direct +-- dependencies at all, because submodule filtering removes the root pom node +-- without rewiring the edges through it. +-- +-- This runs after submodule filtering, which needs the submodule nodes present in +-- the graph to work out which dependencies belong to which submodule. +withoutProjectsAsDeps :: Set Text -> Graphing Dependency -> Graphing Dependency +withoutProjectsAsDeps projectPackages = + shrink (not . isProjectPackage) . promoteToDirect isProjectPackage + where + isProjectPackage :: Dependency -> Bool + isProjectPackage dep = dependencyName dep `Set.member` projectPackages applyMavenFilters :: (Has Diagnostics sig m, Has (Reader MavenScopeFilters) sig m) => Set Text -> Set Text -> Graphing MavenDependency -> m (Graphing Dependency) applyMavenFilters targetSet submoduleSet graph = do diff --git a/test/Maven/PomStrategySpec.hs b/test/Maven/PomStrategySpec.hs index d8cd3419a5..813745499a 100644 --- a/test/Maven/PomStrategySpec.hs +++ b/test/Maven/PomStrategySpec.hs @@ -1,11 +1,30 @@ +{-# LANGUAGE QuasiQuotes #-} + module Maven.PomStrategySpec ( spec, ) where +import Control.Carrier.Reader (runReader) +import Control.Effect.Lift (sendIO) +import Data.ByteString.Char8 qualified as BS +import Data.List (sort) import Data.Map.Strict qualified as Map +import Data.Set qualified as Set +import Data.Set.NonEmpty (nonEmpty) +import Data.Text (Text) +import DepTypes (Dependency (dependencyName)) +import Discovery.Filters (MavenScopeFilters (MavenScopeIncludeFilters)) +import Graphing (Graphing) +import Graphing qualified +import Path (Abs, Dir, File, Path, Rel, reldir, relfile, toFilePath, ()) +import Path.IO qualified as PIO +import Strategy.Maven (MavenProject (MavenProject), getDepsStatically, mkProject) import Strategy.Maven.Pom (MavenPackage (..), buildMavenPackage, interpolateProperties) +import Strategy.Maven.Pom.Closure (findProjects) import Strategy.Maven.Pom.PomFile +import Test.Effect (EffectStack, expectationFailure', itWithTempDir', shouldBe') import Test.Hspec +import Types (BuildTarget (BuildTarget), DependencyResults (dependencyGraph), DiscoveredProject (projectBuildTargets), FoundTargets (FoundTargets, ProjectWithoutTargets)) spec :: Spec spec = do @@ -50,3 +69,167 @@ spec = do } ) result `shouldBe` MavenPackage "MYGROUP" "MYARTIFACT" (Just "MYVERSION") + + -- The pom tactic builds its graph rooted at the project itself. These tests pin + -- that the project's own packages are removed from the reported graph and the + -- dependencies they declare are reported as direct, matching what the dynamic + -- tactics report for the same project. + describe "static analysis of a single-module project" $ do + itWithTempDir' "reports the declared dependencies as direct rather than the project itself" $ \dir -> do + writePom dir [relfile|pom.xml|] singleModulePom + onStaticGraph dir allTargets $ \graph -> + directNames graph `shouldBe'` ["junit:junit", "org.apache.commons:commons-lang3"] + + itWithTempDir' "does not report the project itself as a dependency" $ \dir -> do + writePom dir [relfile|pom.xml|] singleModulePom + onStaticGraph dir allTargets $ \graph -> + vertexNames graph `shouldBe'` ["junit:junit", "org.apache.commons:commons-lang3"] + + describe "static analysis of a multi-module project" $ do + -- commons-lang3 is declared by the root pom, so both submodules inherit it. + itWithTempDir' "reports the dependencies of every module as direct" $ \dir -> do + createMultiModuleFixture dir + onStaticGraph dir allTargets $ \graph -> + directNames graph `shouldBe'` ["com.google.guava:guava", "junit:junit", "org.apache.commons:commons-lang3"] + + itWithTempDir' "does not report the root pom or any submodule as a dependency" $ \dir -> do + createMultiModuleFixture dir + onStaticGraph dir allTargets $ \graph -> + vertexNames graph `shouldBe'` ["com.google.guava:guava", "junit:junit", "org.apache.commons:commons-lang3"] + + -- Submodule filtering deletes the root pom's node, which is the only direct + -- node in the graph the pom tactic builds. Unless the surviving submodules are + -- promoted to direct before being removed, this reports nothing as direct. + itWithTempDir' "reports the selected submodule's dependencies as direct" $ \dir -> do + createMultiModuleFixture dir + onStaticGraph dir (onlyTargets ["com.example:mod-a"]) $ \graph -> + directNames graph `shouldBe'` ["junit:junit", "org.apache.commons:commons-lang3"] + +directNames :: Graphing Dependency -> [Text] +directNames = sort . map dependencyName . Graphing.directList + +vertexNames :: Graphing Dependency -> [Text] +vertexNames = sort . map dependencyName . Graphing.vertexList + +-- | Statically analyze the maven project in @dir@ and hand its dependency graph +-- to @act@. @select@ picks which build targets to analyze. +onStaticGraph :: + Path Abs Dir -> + (MavenProject -> FoundTargets) -> + (Graphing Dependency -> EffectStack ()) -> + EffectStack () +onStaticGraph dir select act = do + closures <- findProjects dir + case closures of + [closure] -> do + let project = MavenProject closure + results <- runReader noScopeFilters $ getDepsStatically (select project) project + act $ dependencyGraph results + -- Each fixture is one project closure; a different count means discovery went + -- wrong, and saying so is more useful than an assertion about the graph. + _ -> expectationFailure' $ "expected one project closure, found " <> show (length closures) + where + noScopeFilters :: MavenScopeFilters + noScopeFilters = MavenScopeIncludeFilters mempty + +-- | Every submodule, as @fossa analyze@ selects them when given no target filter. +allTargets :: MavenProject -> FoundTargets +allTargets = projectBuildTargets . mkProject + +-- | Only the named submodules. +onlyTargets :: [Text] -> MavenProject -> FoundTargets +onlyTargets targets _ = + maybe ProjectWithoutTargets FoundTargets . nonEmpty . Set.fromList $ map BuildTarget targets + +writePom :: Path Abs Dir -> Path Rel File -> BS.ByteString -> EffectStack () +writePom dir name = sendIO . BS.writeFile (toFilePath (dir name)) + +-- | Writes a two-module project into @dir@: +-- +-- @ +-- pom.xml -- com.example:root:1.0, packaging=pom, declares commons-lang3 +-- mod-a/pom.xml -- declares junit +-- mod-b/pom.xml -- declares guava +-- @ +createMultiModuleFixture :: Path Abs Dir -> EffectStack () +createMultiModuleFixture dir = do + sendIO $ PIO.createDirIfMissing True (dir [reldir|mod-a|]) + sendIO $ PIO.createDirIfMissing True (dir [reldir|mod-b|]) + writePom dir [relfile|pom.xml|] multiModuleRootPom + writePom (dir [reldir|mod-a|]) [relfile|pom.xml|] (modulePom "mod-a" "junit" "junit" "4.13.2") + writePom (dir [reldir|mod-b|]) [relfile|pom.xml|] (modulePom "mod-b" "com.google.guava" "guava" "31.1-jre") + +-- | Concatenates lines of an XML document. +packLines :: [String] -> BS.ByteString +packLines = BS.concat . map BS.pack + +singleModulePom :: BS.ByteString +singleModulePom = + packLines + [ "\n" + , "\n" + , " 4.0.0\n" + , " com.example\n" + , " demo\n" + , " 1.0\n" + , " \n" + , " \n" + , " junit\n" + , " junit\n" + , " 4.13.2\n" + , " \n" + , " \n" + , " org.apache.commons\n" + , " commons-lang3\n" + , " 3.12.0\n" + , " \n" + , " \n" + , "\n" + ] + +multiModuleRootPom :: BS.ByteString +multiModuleRootPom = + packLines + [ "\n" + , "\n" + , " 4.0.0\n" + , " com.example\n" + , " root\n" + , " 1.0\n" + , " pom\n" + , " \n" + , " mod-a\n" + , " mod-b\n" + , " \n" + , " \n" + , " \n" + , " org.apache.commons\n" + , " commons-lang3\n" + , " 3.12.0\n" + , " \n" + , " \n" + , "\n" + ] + +modulePom :: String -> String -> String -> String -> BS.ByteString +modulePom artifactId depGroup depArtifact depVersion' = + packLines + [ "\n" + , "\n" + , " 4.0.0\n" + , " \n" + , " com.example\n" + , " root\n" + , " 1.0\n" + , " ../pom.xml\n" + , " \n" + , " " <> artifactId <> "\n" + , " \n" + , " \n" + , " " <> depGroup <> "\n" + , " " <> depArtifact <> "\n" + , " " <> depVersion' <> "\n" + , " \n" + , " \n" + , "\n" + ] From d993e1f7ce9765291d91ed7cfff9c0e22bc71814 Mon Sep 17 00:00:00 2001 From: spatten Date: Thu, 3 Sep 2026 10:10:32 -0700 Subject: [PATCH 2/2] Link the changelog entry to the PR Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QrDtxeRNysKQz4NWXixRzU --- Changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Changelog.md b/Changelog.md index da8c458b75..7f557ea779 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,7 +2,7 @@ ## Unreleased -- Maven: Static analysis of `pom.xml` files now reports the dependencies a project declares as direct. Previously the project itself was reported as the only direct dependency and everything it declared was reported as transitive, and in a multi-module project the root pom and every submodule were also reported as dependencies. This affects analysis without `mvn` on `PATH`, `--static-only-analysis`, and container scanning; analysis using `mvn` was already correct. Selecting a submodule with `--only-target` now reports that submodule's dependencies as direct instead of reporting none at all. +- Maven: Static analysis of `pom.xml` files now reports the dependencies a project declares as direct. Previously the project itself was reported as the only direct dependency and everything it declared was reported as transitive, and in a multi-module project the root pom and every submodule were also reported as dependencies. This affects analysis without `mvn` on `PATH`, `--static-only-analysis`, and container scanning; analysis using `mvn` was already correct. Selecting a submodule with `--only-target` now reports that submodule's dependencies as direct instead of reporting none at all. ([#1768](https://github.com/fossas/fossa-cli/pull/1768)) - Diagnostics: When an error or warning group contains multiple errors, each error's `Traceback:` header is now printed on its own line instead of being glued onto the last line of the preceding error message (e.g. `...none passed validationTraceback:`). ([#1758](https://github.com/fossas/fossa-cli/pull/1758)) ## 3.18.2