From a67b2c1ee439de1296b2b84ba7c8283590bc030d Mon Sep 17 00:00:00 2001 From: Sara Date: Wed, 8 Jul 2026 18:27:31 -0400 Subject: [PATCH 1/3] Maven: recover duplicate-resolved dependency edges from the depgraph plugin Maven's dependency mediation attaches a package shared by several parents to a single winning parent. The depgraph plugin's aggregate goal only ever emits those winning edges (it hardcodes NodeResolution.INCLUDED), and its text format cannot express duplicates regardless of flags, so the CLI uploads a graph in which a shared transitive dependency appears exclusive to one parent. Downstream, dependency paths under-report who actually uses a shared package. Recover the omitted edges with a second, best-effort plugin invocation: the non-aggregating graph goal with showDuplicates=true emits JSON in which Maven's verbose graph reports the hidden edges as OMITTED_FOR_DUPLICATE. Those edges are merged into the aggregate output before the graph is built. On any failure the aggregate output is used unchanged, matching previous behavior. Edges are joined to artifacts via the JSON string ids: depgraph's numericId / numericFrom / numericTo use two unrelated counters and cannot be used as a join key. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01X9wbLEaAC42dirqDFT7eEn --- src/Strategy/Maven/Plugin.hs | 150 ++++++++++++++++++++++++++- src/Strategy/Maven/PluginStrategy.hs | 39 ++++++- test/Maven/PluginSpec.hs | 124 +++++++++++++++++++++- 3 files changed, 308 insertions(+), 5 deletions(-) diff --git a/src/Strategy/Maven/Plugin.hs b/src/Strategy/Maven/Plugin.hs index 64e19a8216..288c137b3e 100644 --- a/src/Strategy/Maven/Plugin.hs +++ b/src/Strategy/Maven/Plugin.hs @@ -5,6 +5,7 @@ module Strategy.Maven.Plugin ( withUnpackedPlugin, installPlugin, parsePluginOutput, + parseVerboseGraphs, depGraphPlugin, depGraphPluginLegacy, Artifact (..), @@ -13,10 +14,15 @@ module Strategy.Maven.Plugin ( PluginOutput (..), ReactorOutput (..), ReactorArtifact (..), + VerboseArtifact (..), + VerboseEdge (..), + VerboseGraph (..), + augmentWithDuplicateEdges, parseReactorOutput, textArtifactToPluginOutput, execPluginAggregate, execPluginReactor, + execPluginVerboseGraph, mavenCmdCandidates, ) where @@ -36,13 +42,13 @@ import Data.List.NonEmpty (NonEmpty (..)) import Data.List.NonEmpty qualified as NE import Data.Map (Map) import Data.Map qualified as Map -import Data.Maybe (catMaybes, isNothing) +import Data.Maybe (catMaybes, isNothing, mapMaybe, maybeToList) import Data.String.Conversion (toText) import Data.Text (Text) import Data.Traversable (for) import Data.Tree (Tree (..)) import DepTypes (DepType (MavenType)) -import Discovery.Walk (findFileInAncestor) +import Discovery.Walk (WalkStep (WalkContinue), findFileInAncestor, findFileNamed, walk') import Effect.Exec ( AllowErr (Never), CandidateAnalysisCommands (..), @@ -133,6 +139,11 @@ execPluginReactor projectdir outputdir plugin = do cmd <- mavenPluginReactorCmd projectdir outputdir plugin execPlugin (const cmd) projectdir plugin +execPluginVerboseGraph :: (CandidateCommandEffs sig m, Has ReadFS sig m) => Path Abs Dir -> DepGraphPlugin -> m () +execPluginVerboseGraph dir plugin = do + cmd <- mavenPluginVerboseGraphCmd dir plugin + execPlugin (const cmd) dir plugin + outputFile :: Path Rel File outputFile = $(mkRelFile "target/dependency-graph.txt") @@ -146,6 +157,19 @@ reactorOutputFilename = $(mkRelFile "fossa-reactor-graph.json") parseReactorOutput :: (Has ReadFS sig m, Has Diagnostics sig m) => (Path Abs Dir) -> m ReactorOutput parseReactorOutput dir = readContentsJson $ dir reactorOutputFilename +verboseGraphFileName :: String +verboseGraphFileName = "fossa-depgraph-verbose.json" + +-- | Find and parse the per-module output of 'execPluginVerboseGraph'. +-- +-- The @graph@ goal is not an aggregator: in a multi-module build it runs once +-- per reactor module and writes into each module's own build directory, so we +-- walk the project tree to collect every output file. +parseVerboseGraphs :: (Has ReadFS sig m, Has Diagnostics sig m) => Path Abs Dir -> m [VerboseGraph] +parseVerboseGraphs dir = do + outputs <- walk' (\_ _ files -> pure (maybeToList (findFileNamed verboseGraphFileName files), WalkContinue)) dir + traverse readContentsJson outputs + textArtifactToPluginOutput :: Has Diagnostics sig m => Tree TextArtifact -> m PluginOutput textArtifactToPluginOutput ta = buildPluginOutput ta @@ -262,6 +286,35 @@ mavenPluginDependenciesCmd workdir plugin = do "-DrepeatTransitiveDependenciesInTextGraph=true" ] +-- | The (non-aggregating) graph command is documented +-- [here.](https://ferstl.github.io/depgraph-maven-plugin/graph-mojo.html) +-- +-- Maven's dependency mediation attaches a package shared by several parents to +-- a single winning parent, omitting the other edges from the resolved graph. +-- The @aggregate@ goal only ever emits the winning edges (it hardcodes +-- @NodeResolution.INCLUDED@), so 'mavenPluginDependenciesCmd' alone +-- mis-attributes shared transitive dependencies to a single parent. +-- +-- Unlike @aggregate@, the @graph@ goal supports @showDuplicates@, which asks +-- Maven for the verbose graph and reports the omitted edges with +-- @"resolution": "OMITTED_FOR_DUPLICATE"@. This command recovers those edges; +-- its JSON output is merged into the aggregate output by +-- 'augmentWithDuplicateEdges'. +mavenPluginVerboseGraphCmd :: (CandidateCommandEffs sig m, Has ReadFS sig m) => Path Abs Dir -> DepGraphPlugin -> m Command +mavenPluginVerboseGraphCmd workdir plugin = do + candidates <- mavenCmdCandidates workdir + mkAnalysisCommand candidates workdir args Never + where + args = + [ group plugin <> ":" <> artifact plugin <> ":" <> version plugin <> ":graph" + , "-DgraphFormat=json" + , -- match the node identity used by the aggregate command + "-DmergeScopes" + , -- request Maven's verbose graph so duplicate-resolved edges are reported + "-DshowDuplicates=true" + , "-DoutputFileName=" <> toText verboseGraphFileName + ] + -- | The reactor command is documented -- [here.](https://ferstl.github.io/depgraph-maven-plugin/reactor-mojo.html) -- We set outputDirectory explicitly so that the file is written to a known spot even if the pom file @@ -329,3 +382,96 @@ data Edge = Edge , edgeTo :: Int } deriving (Eq, Ord, Show) + +-- | A dependency graph as produced by the depgraph plugin's JSON format. +-- +-- Edges are matched to artifacts via the string @id@ field. The plugin also +-- emits @numericId@\/@numericFrom@\/@numericTo@, but those use two unrelated +-- counters (observed with depgraph 4.0.1: an edge's @numericTo@ does not point +-- at the artifact carrying that @numericId@), so they cannot be used as a join +-- key. +data VerboseGraph = VerboseGraph + { verboseArtifacts :: [VerboseArtifact] + , verboseEdges :: [VerboseEdge] + } + deriving (Eq, Ord, Show) + +instance FromJSON VerboseGraph where + parseJSON = withObject "Verbose graph" $ + \o -> + VerboseGraph + <$> (o .:? "artifacts" .!= []) + <*> (o .:? "dependencies" .!= []) + +data VerboseArtifact = VerboseArtifact + { verboseArtifactId :: Text + , verboseArtifactGroupId :: Text + , verboseArtifactArtifactId :: Text + , verboseArtifactVersion :: Text + } + deriving (Eq, Ord, Show) + +instance FromJSON VerboseArtifact where + parseJSON = withObject "Verbose graph artifact" $ + \o -> + VerboseArtifact + <$> o .: "id" + <*> o .: "groupId" + <*> o .: "artifactId" + <*> o .: "version" + +data VerboseEdge = VerboseEdge + { verboseEdgeFrom :: Text + , verboseEdgeTo :: Text + , verboseEdgeResolution :: Text + } + deriving (Eq, Ord, Show) + +instance FromJSON VerboseEdge where + parseJSON = withObject "Verbose graph dependency" $ + \o -> + VerboseEdge + <$> o .: "from" + <*> o .: "to" + <*> (o .:? "resolution" .!= "INCLUDED") + +-- | Merge duplicate-resolved edges recovered by 'execPluginVerboseGraph' into +-- the graph parsed from the aggregate output. +-- +-- Only @OMITTED_FOR_DUPLICATE@ edges are merged: a duplicate is the same +-- group\/artifact\/version as the winning node, so both endpoints already exist +-- in the aggregate output and the edge can be added without introducing new +-- nodes. @OMITTED_FOR_CONFLICT@ edges point at a *losing* version that the +-- build does not ship, so they are intentionally left out. +augmentWithDuplicateEdges :: PluginOutput -> [VerboseGraph] -> PluginOutput +augmentWithDuplicateEdges output@PluginOutput{outArtifacts, outEdges} verboseGraphs = + output{outEdges = nub (outEdges <> concatMap duplicateEdges verboseGraphs)} + where + idByGav :: Map (Text, Text, Text) Int + idByGav = + Map.fromList $ + map (\a -> ((artifactGroupId a, artifactArtifactId a, artifactVersion a), artifactNumericId a)) outArtifacts + + duplicateEdges :: VerboseGraph -> [Edge] + duplicateEdges VerboseGraph{verboseArtifacts, verboseEdges} = mapMaybe toAggregateEdge verboseEdges + where + gavByVerboseId :: Map Text (Text, Text, Text) + gavByVerboseId = + Map.fromList $ + map + (\a -> (verboseArtifactId a, (verboseArtifactGroupId a, verboseArtifactArtifactId a, verboseArtifactVersion a))) + verboseArtifacts + + lookupAggregateId :: Text -> Maybe Int + lookupAggregateId verboseId = Map.lookup verboseId gavByVerboseId >>= (`Map.lookup` idByGav) + + toAggregateEdge :: VerboseEdge -> Maybe Edge + toAggregateEdge VerboseEdge{verboseEdgeFrom, verboseEdgeTo, verboseEdgeResolution} = + if verboseEdgeResolution /= "OMITTED_FOR_DUPLICATE" + then Nothing + else case (lookupAggregateId verboseEdgeFrom, lookupAggregateId verboseEdgeTo) of + (Just parent, Just child) -> + if parent == child + then Nothing + else Just (Edge parent child) + _ -> Nothing diff --git a/src/Strategy/Maven/PluginStrategy.hs b/src/Strategy/Maven/PluginStrategy.hs index d146bdeb96..bdff2f438e 100644 --- a/src/Strategy/Maven/PluginStrategy.hs +++ b/src/Strategy/Maven/PluginStrategy.hs @@ -11,6 +11,7 @@ import Control.Effect.Diagnostics ( ToDiagnostic (renderDiagnostic), context, errCtx, + recover, warnOnErr, (<||>), ) @@ -20,6 +21,7 @@ import Control.Monad (when) import Data.Foldable (traverse_) import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map +import Data.Maybe (fromMaybe) import Data.Set qualified as Set import Data.Text (Text) import DepTypes ( @@ -42,13 +44,16 @@ import Strategy.Maven.Plugin ( Edge (..), PluginOutput (..), ReactorOutput (ReactorOutput), + augmentWithDuplicateEdges, depGraphPlugin, depGraphPluginLegacy, execPluginAggregate, execPluginReactor, + execPluginVerboseGraph, installPlugin, parsePluginOutput, parseReactorOutput, + parseVerboseGraphs, reactorArtifactName, reactorArtifacts, withUnpackedPlugin, @@ -103,9 +108,35 @@ analyze dir plugin = do errCtx MvnPluginExecFailed $ execPluginAggregate dir plugin pluginOutput <- parsePluginOutput dir - context "Building dependency graph" $ pure (buildGraph reactorOutput pluginOutput) + pluginOutput' <- recoverDuplicateEdges dir plugin pluginOutput + context "Building dependency graph" $ pure (buildGraph reactorOutput pluginOutput') pure (graph, Complete) +-- | Maven's dependency mediation attaches a package shared by several parents +-- to a single winning parent; the aggregate goal only reports those winning +-- edges, so a shared transitive dependency looks exclusive to one parent (see +-- 'Strategy.Maven.Plugin.mavenPluginVerboseGraphCmd'). Recover the omitted +-- edges with a second plugin run and merge them into the parsed output. +-- +-- Recovery is best-effort: on any failure the aggregate output is used as-is, +-- which matches the behavior before this step existed. +recoverDuplicateEdges :: + ( CandidateCommandEffs sig m + , Has ReadFS sig m + ) => + Path Abs Dir -> + DepGraphPlugin -> + PluginOutput -> + m PluginOutput +recoverDuplicateEdges dir plugin pluginOutput = + context "Running plugin to recover duplicate-resolved edges" $ do + recovered <- + recover $ + warnOnErr DuplicateEdgesNotRecovered $ do + execPluginVerboseGraph dir plugin + augmentWithDuplicateEdges pluginOutput <$> parseVerboseGraphs dir + pure (fromMaybe pluginOutput recovered) + data MvnPluginInstallFailed = MvnPluginInstallFailed instance ToDiagnostic MvnPluginInstallFailed where renderDiagnostic (MvnPluginInstallFailed) = do @@ -118,6 +149,12 @@ instance ToDiagnostic MvnPluginExecFailed where let header = "Failed to execute maven plugin for analysis" Errata (Just header) [] Nothing +data DuplicateEdgesNotRecovered = DuplicateEdgesNotRecovered +instance ToDiagnostic DuplicateEdgesNotRecovered where + renderDiagnostic DuplicateEdgesNotRecovered = do + let header = "Failed to recover duplicate-resolved dependency edges; transitive dependencies shared between multiple parents may be attributed to only one of them." + Errata (Just header) [] Nothing + data MayIncludeSubmodule = MayIncludeSubmodule instance ToDiagnostic MayIncludeSubmodule where renderDiagnostic MayIncludeSubmodule = do diff --git a/test/Maven/PluginSpec.hs b/test/Maven/PluginSpec.hs index 58b9be30f8..0b94fd993c 100644 --- a/test/Maven/PluginSpec.hs +++ b/test/Maven/PluginSpec.hs @@ -2,18 +2,31 @@ module Maven.PluginSpec (spec) where +import Data.Aeson (eitherDecode) +import Data.ByteString.Lazy.Char8 qualified as BS import Data.Text (Text) import Data.Tree (Tree (..)) -import Strategy.Maven.Plugin (Artifact (..), Edge (..), PluginOutput (..), textArtifactToPluginOutput) +import Strategy.Maven.Plugin ( + Artifact (..), + Edge (..), + PluginOutput (..), + VerboseArtifact (..), + VerboseEdge (..), + VerboseGraph (..), + augmentWithDuplicateEdges, + textArtifactToPluginOutput, + ) import Strategy.Maven.PluginTree (TextArtifact (..), parseTextArtifact) import Test.Effect (expectationFailure', it', shouldBe', shouldContain', shouldMatchList') -import Test.Hspec (Spec, describe) +import Test.Hspec (Spec, describe, it, shouldBe) import Text.Megaparsec (parseMaybe) import Text.RawString.QQ (r) spec :: Spec spec = do textArtifactConversionSpec + verboseGraphParsingSpec + augmentWithDuplicateEdgesSpec singleTextArtifact :: TextArtifact singleTextArtifact = @@ -201,3 +214,110 @@ kafkaClientCompile = Artifact 6 "org.apache.kafka" "kafka-clients" "3.0.2" ["com kafkaClientTest :: Artifact kafkaClientTest = kafkaClientCompile{artifactNumericId = 1, artifactScopes = ["test"]} + +-- | Shape of the depgraph plugin's JSON output with @showDuplicates=true@. +-- Maven resolved log4j-api under poi-ooxml; the log4j-core edge to it was +-- omitted as a duplicate and is only visible in the verbose graph. +-- +-- The numericId/numericFrom/numericTo values deliberately do NOT line up: +-- depgraph numbers artifacts and edge endpoints with unrelated counters, so +-- the parser must join edges to artifacts via the string ids. +verboseGraphJson :: BS.ByteString +verboseGraphJson = + BS.pack + [r|{ + "graphName": "example", + "artifacts": [ + { "id": "org.example:app:jar", "numericId": 7, "groupId": "org.example", "artifactId": "app", "version": "1.0.0", "scopes": [], "types": ["jar"] }, + { "id": "org.apache.poi:poi-ooxml:jar", "numericId": 3, "groupId": "org.apache.poi", "artifactId": "poi-ooxml", "version": "5.2.5", "scopes": ["compile"], "types": ["jar"] }, + { "id": "org.apache.logging.log4j:log4j-api:jar", "numericId": 9, "groupId": "org.apache.logging.log4j", "artifactId": "log4j-api", "version": "2.21.1", "scopes": ["compile"], "types": ["jar"] }, + { "id": "org.apache.logging.log4j:log4j-core:jar", "numericId": 1, "groupId": "org.apache.logging.log4j", "artifactId": "log4j-core", "version": "2.21.1", "scopes": ["compile"], "types": ["jar"] } + ], + "dependencies": [ + { "from": "org.example:app:jar", "to": "org.apache.poi:poi-ooxml:jar", "numericFrom": 1, "numericTo": 2, "resolution": "INCLUDED" }, + { "from": "org.example:app:jar", "to": "org.apache.logging.log4j:log4j-core:jar", "numericFrom": 1, "numericTo": 4, "resolution": "INCLUDED" }, + { "from": "org.apache.poi:poi-ooxml:jar", "to": "org.apache.logging.log4j:log4j-api:jar", "numericFrom": 2, "numericTo": 3, "resolution": "INCLUDED" }, + { "from": "org.apache.logging.log4j:log4j-core:jar", "to": "org.apache.logging.log4j:log4j-api:jar", "numericFrom": 4, "numericTo": 3, "resolution": "OMITTED_FOR_DUPLICATE" } + ] +}|] + +expectedVerboseGraph :: VerboseGraph +expectedVerboseGraph = + VerboseGraph + { verboseArtifacts = + [ VerboseArtifact "org.example:app:jar" "org.example" "app" "1.0.0" + , VerboseArtifact "org.apache.poi:poi-ooxml:jar" "org.apache.poi" "poi-ooxml" "5.2.5" + , VerboseArtifact "org.apache.logging.log4j:log4j-api:jar" "org.apache.logging.log4j" "log4j-api" "2.21.1" + , VerboseArtifact "org.apache.logging.log4j:log4j-core:jar" "org.apache.logging.log4j" "log4j-core" "2.21.1" + ] + , verboseEdges = + [ VerboseEdge "org.example:app:jar" "org.apache.poi:poi-ooxml:jar" "INCLUDED" + , VerboseEdge "org.example:app:jar" "org.apache.logging.log4j:log4j-core:jar" "INCLUDED" + , VerboseEdge "org.apache.poi:poi-ooxml:jar" "org.apache.logging.log4j:log4j-api:jar" "INCLUDED" + , VerboseEdge "org.apache.logging.log4j:log4j-core:jar" "org.apache.logging.log4j:log4j-api:jar" "OMITTED_FOR_DUPLICATE" + ] + } + +verboseGraphParsingSpec :: Spec +verboseGraphParsingSpec = + describe "verbose graph parsing" $ + it "should parse the depgraph plugin's json format" $ + eitherDecode verboseGraphJson `shouldBe` Right expectedVerboseGraph + +-- Aggregate output for the same build. Numeric ids deliberately differ from +-- the verbose graph's ids: the two runs number artifacts independently, so the +-- merge must map through group/artifact/version. +mkAggregateArtifact :: Int -> Text -> Text -> Text -> Bool -> Artifact +mkAggregateArtifact numericId groupId artifactId version isDirect = + Artifact + { artifactNumericId = numericId + , artifactGroupId = groupId + , artifactArtifactId = artifactId + , artifactVersion = version + , artifactScopes = ["compile"] + , artifactOptional = False + , artifactIsDirect = isDirect + } + +aggregateOutput :: PluginOutput +aggregateOutput = + PluginOutput + { outArtifacts = + [ mkAggregateArtifact 10 "org.apache.poi" "poi-ooxml" "5.2.5" True + , mkAggregateArtifact 11 "org.apache.logging.log4j" "log4j-api" "2.21.1" False + , mkAggregateArtifact 12 "org.apache.logging.log4j" "log4j-core" "2.21.1" True + ] + , outEdges = [Edge 10 11] + } + +augmentWithDuplicateEdgesSpec :: Spec +augmentWithDuplicateEdgesSpec = + describe "augmentWithDuplicateEdges" $ do + it "should add duplicate-resolved edges between existing artifacts" $ + outEdges (augmentWithDuplicateEdges aggregateOutput [expectedVerboseGraph]) + `shouldBe` [Edge 10 11, Edge 12 11] + + it "should not modify artifacts" $ + outArtifacts (augmentWithDuplicateEdges aggregateOutput [expectedVerboseGraph]) + `shouldBe` outArtifacts aggregateOutput + + it "should ignore included edges and artifacts absent from the aggregate output" $ do + -- org.example:app is not in the aggregate output, so the INCLUDED edges + -- rooted there must not produce anything even if they were considered. + let onlyIncluded = + expectedVerboseGraph + { verboseEdges = + [ VerboseEdge "org.example:app:jar" "org.apache.poi:poi-ooxml:jar" "INCLUDED" + , VerboseEdge "org.apache.poi:poi-ooxml:jar" "org.apache.logging.log4j:log4j-api:jar" "INCLUDED" + ] + } + outEdges (augmentWithDuplicateEdges aggregateOutput [onlyIncluded]) + `shouldBe` outEdges aggregateOutput + + it "should not duplicate an edge that already exists" $ do + let alreadyPresent = aggregateOutput{outEdges = [Edge 10 11, Edge 12 11]} + outEdges (augmentWithDuplicateEdges alreadyPresent [expectedVerboseGraph]) + `shouldBe` [Edge 10 11, Edge 12 11] + + it "should do nothing without verbose graphs" $ + augmentWithDuplicateEdges aggregateOutput [] `shouldBe` aggregateOutput From eea2ab96403e3063636e5a0fa85727c56ea08947 Mon Sep 17 00:00:00 2001 From: Sara Date: Wed, 8 Jul 2026 18:30:31 -0400 Subject: [PATCH 2/3] Add changelog entry Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01X9wbLEaAC42dirqDFT7eEn --- Changelog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Changelog.md b/Changelog.md index fe9f72312c..27da12a82f 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,9 @@ # FOSSA CLI Changelog +## 3.17.14 + +- Maven: Recover dependency edges that Maven's resolution omits as duplicates, so transitive dependencies shared by multiple parents are attributed to all of them instead of a single winning parent. ([#1730](https://github.com/fossas/fossa-cli/pull/1730)) + ## 3.17.13 - Licensing: Detect the Lucky Penny Software RPL-1.5 dual-license notice (AutoMapper, MediatR), resolving it to `rpl-1.5 OR proprietary-license` instead of `unknown`. ([#1729](https://github.com/fossas/fossa-cli/pull/1729)) From e3e9310eb40be5e07dff48807b13441beeb31a06 Mon Sep 17 00:00:00 2001 From: Sara Date: Wed, 8 Jul 2026 18:38:43 -0400 Subject: [PATCH 3/3] Trim comments to essentials Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01X9wbLEaAC42dirqDFT7eEn --- src/Strategy/Maven/Plugin.hs | 47 ++++++++-------------------- src/Strategy/Maven/PluginStrategy.hs | 11 ++----- test/Maven/PluginSpec.hs | 16 +++------- 3 files changed, 20 insertions(+), 54 deletions(-) diff --git a/src/Strategy/Maven/Plugin.hs b/src/Strategy/Maven/Plugin.hs index 288c137b3e..34febd511e 100644 --- a/src/Strategy/Maven/Plugin.hs +++ b/src/Strategy/Maven/Plugin.hs @@ -160,11 +160,8 @@ parseReactorOutput dir = readContentsJson $ dir reactorOutputFilename verboseGraphFileName :: String verboseGraphFileName = "fossa-depgraph-verbose.json" --- | Find and parse the per-module output of 'execPluginVerboseGraph'. --- --- The @graph@ goal is not an aggregator: in a multi-module build it runs once --- per reactor module and writes into each module's own build directory, so we --- walk the project tree to collect every output file. +-- | Find and parse the output of 'execPluginVerboseGraph'. The @graph@ goal +-- runs per reactor module, writing into each module's build directory. parseVerboseGraphs :: (Has ReadFS sig m, Has Diagnostics sig m) => Path Abs Dir -> m [VerboseGraph] parseVerboseGraphs dir = do outputs <- walk' (\_ _ files -> pure (maybeToList (findFileNamed verboseGraphFileName files), WalkContinue)) dir @@ -286,20 +283,12 @@ mavenPluginDependenciesCmd workdir plugin = do "-DrepeatTransitiveDependenciesInTextGraph=true" ] --- | The (non-aggregating) graph command is documented --- [here.](https://ferstl.github.io/depgraph-maven-plugin/graph-mojo.html) --- --- Maven's dependency mediation attaches a package shared by several parents to --- a single winning parent, omitting the other edges from the resolved graph. --- The @aggregate@ goal only ever emits the winning edges (it hardcodes --- @NodeResolution.INCLUDED@), so 'mavenPluginDependenciesCmd' alone --- mis-attributes shared transitive dependencies to a single parent. --- --- Unlike @aggregate@, the @graph@ goal supports @showDuplicates@, which asks --- Maven for the verbose graph and reports the omitted edges with --- @"resolution": "OMITTED_FOR_DUPLICATE"@. This command recovers those edges; --- its JSON output is merged into the aggregate output by --- 'augmentWithDuplicateEdges'. +-- | The @aggregate@ goal above cannot report edges Maven resolved away as +-- duplicates (it hardcodes @NodeResolution.INCLUDED@ and has no +-- @showDuplicates@ option), so a package shared by several parents appears +-- under only one of them. The non-aggregating +-- [graph goal](https://ferstl.github.io/depgraph-maven-plugin/graph-mojo.html) +-- with @showDuplicates@ reports those edges as @OMITTED_FOR_DUPLICATE@. mavenPluginVerboseGraphCmd :: (CandidateCommandEffs sig m, Has ReadFS sig m) => Path Abs Dir -> DepGraphPlugin -> m Command mavenPluginVerboseGraphCmd workdir plugin = do candidates <- mavenCmdCandidates workdir @@ -383,13 +372,8 @@ data Edge = Edge } deriving (Eq, Ord, Show) --- | A dependency graph as produced by the depgraph plugin's JSON format. --- --- Edges are matched to artifacts via the string @id@ field. The plugin also --- emits @numericId@\/@numericFrom@\/@numericTo@, but those use two unrelated --- counters (observed with depgraph 4.0.1: an edge's @numericTo@ does not point --- at the artifact carrying that @numericId@), so they cannot be used as a join --- key. +-- | The depgraph plugin's JSON format. Edges join to artifacts via the string +-- @id@ field; the numeric ids use two unrelated counters and cannot be used. data VerboseGraph = VerboseGraph { verboseArtifacts :: [VerboseArtifact] , verboseEdges :: [VerboseEdge] @@ -435,14 +419,9 @@ instance FromJSON VerboseEdge where <*> o .: "to" <*> (o .:? "resolution" .!= "INCLUDED") --- | Merge duplicate-resolved edges recovered by 'execPluginVerboseGraph' into --- the graph parsed from the aggregate output. --- --- Only @OMITTED_FOR_DUPLICATE@ edges are merged: a duplicate is the same --- group\/artifact\/version as the winning node, so both endpoints already exist --- in the aggregate output and the edge can be added without introducing new --- nodes. @OMITTED_FOR_CONFLICT@ edges point at a *losing* version that the --- build does not ship, so they are intentionally left out. +-- | Merge @OMITTED_FOR_DUPLICATE@ edges into the aggregate output; both +-- endpoints of a duplicate already exist there. @OMITTED_FOR_CONFLICT@ edges +-- point at a losing version the build does not ship, so they are skipped. augmentWithDuplicateEdges :: PluginOutput -> [VerboseGraph] -> PluginOutput augmentWithDuplicateEdges output@PluginOutput{outArtifacts, outEdges} verboseGraphs = output{outEdges = nub (outEdges <> concatMap duplicateEdges verboseGraphs)} diff --git a/src/Strategy/Maven/PluginStrategy.hs b/src/Strategy/Maven/PluginStrategy.hs index bdff2f438e..c0240448a1 100644 --- a/src/Strategy/Maven/PluginStrategy.hs +++ b/src/Strategy/Maven/PluginStrategy.hs @@ -112,14 +112,9 @@ analyze dir plugin = do context "Building dependency graph" $ pure (buildGraph reactorOutput pluginOutput') pure (graph, Complete) --- | Maven's dependency mediation attaches a package shared by several parents --- to a single winning parent; the aggregate goal only reports those winning --- edges, so a shared transitive dependency looks exclusive to one parent (see --- 'Strategy.Maven.Plugin.mavenPluginVerboseGraphCmd'). Recover the omitted --- edges with a second plugin run and merge them into the parsed output. --- --- Recovery is best-effort: on any failure the aggregate output is used as-is, --- which matches the behavior before this step existed. +-- | Recover edges Maven resolved away as duplicates (see +-- 'Strategy.Maven.Plugin.mavenPluginVerboseGraphCmd') and merge them into the +-- parsed output. Best-effort: on failure the aggregate output is used as-is. recoverDuplicateEdges :: ( CandidateCommandEffs sig m , Has ReadFS sig m diff --git a/test/Maven/PluginSpec.hs b/test/Maven/PluginSpec.hs index 0b94fd993c..a3508d5bf8 100644 --- a/test/Maven/PluginSpec.hs +++ b/test/Maven/PluginSpec.hs @@ -215,13 +215,9 @@ kafkaClientCompile = Artifact 6 "org.apache.kafka" "kafka-clients" "3.0.2" ["com kafkaClientTest :: Artifact kafkaClientTest = kafkaClientCompile{artifactNumericId = 1, artifactScopes = ["test"]} --- | Shape of the depgraph plugin's JSON output with @showDuplicates=true@. --- Maven resolved log4j-api under poi-ooxml; the log4j-core edge to it was --- omitted as a duplicate and is only visible in the verbose graph. --- --- The numericId/numericFrom/numericTo values deliberately do NOT line up: --- depgraph numbers artifacts and edge endpoints with unrelated counters, so --- the parser must join edges to artifacts via the string ids. +-- | depgraph JSON with @showDuplicates=true@. The numeric ids deliberately do +-- not line up: depgraph numbers artifacts and edge endpoints independently, so +-- edges must join to artifacts via the string ids. verboseGraphJson :: BS.ByteString verboseGraphJson = BS.pack @@ -264,9 +260,7 @@ verboseGraphParsingSpec = it "should parse the depgraph plugin's json format" $ eitherDecode verboseGraphJson `shouldBe` Right expectedVerboseGraph --- Aggregate output for the same build. Numeric ids deliberately differ from --- the verbose graph's ids: the two runs number artifacts independently, so the --- merge must map through group/artifact/version. +-- Aggregate output for the same build, with its own unrelated numeric ids. mkAggregateArtifact :: Int -> Text -> Text -> Text -> Bool -> Artifact mkAggregateArtifact numericId groupId artifactId version isDirect = Artifact @@ -302,8 +296,6 @@ augmentWithDuplicateEdgesSpec = `shouldBe` outArtifacts aggregateOutput it "should ignore included edges and artifacts absent from the aggregate output" $ do - -- org.example:app is not in the aggregate output, so the INCLUDED edges - -- rooted there must not produce anything even if they were considered. let onlyIncluded = expectedVerboseGraph { verboseEdges =