diff --git a/Changelog.md b/Changelog.md index df8093661..85142ac52 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,9 @@ # FOSSA CLI Changelog +## Unreleased + +- Scala/sbt: Run the uppercase `dependencyBrowseTreeHTML` task when the project explicitly enables `addDependencyTreePlugin` on sbt 1.4+. Previously the lowercase `dependencyBrowseTreeHtml` was used unconditionally for the explicit-plugin path, which sbt 1.4+ rejects, causing deep dependencies to be silently dropped. ([TKT-15490](https://fossa.atlassian.net/browse/ANE-2718)) + ## 3.17.6 - Config: `paths.only` and `paths.exclude` in `.fossa.yml` now accept glob patterns. ([#1703](https://github.com/fossas/fossa-cli/pull/1703)) diff --git a/src/Strategy/Scala.hs b/src/Strategy/Scala.hs index 6b85919f4..dbcd3278f 100644 --- a/src/Strategy/Scala.hs +++ b/src/Strategy/Scala.hs @@ -60,7 +60,7 @@ import Strategy.Maven.Pom.PomFile (RawPom (rawPomArtifact, rawPomGroup, rawPomVe import Strategy.Maven.Pom.Resolver (buildGlobalClosure) import Strategy.Scala.Common (mkSbtCommand) import Strategy.Scala.Errors (FailedToListProjects (FailedToListProjects), MaybeWithoutDependencyTreeTask (..), MissingFullDependencyPlugin (..), sbtDepsGraphPluginUrl, scalaFossaDocUrl) -import Strategy.Scala.Plugin (genTreeJson, hasDependencyPlugins) +import Strategy.Scala.Plugin (DependencyPluginsDetected (..), genTreeJson, hasDependencyPlugins) import Strategy.Scala.SbtDependencyTree (SbtArtifact (SbtArtifact), analyze, sbtDepTreeCmd) import Strategy.Scala.SbtDependencyTreeJson qualified as TreeJson import Types ( @@ -161,10 +161,10 @@ findProjects = walkWithFilters' $ \dir _ files -> do . context ("Listing sbt projects at " <> pathToText dir) $ genPoms dir - (miniDepPlugin, depPlugin) <- hasDependencyPlugins dir - case (projectsRes, miniDepPlugin, depPlugin) of + DependencyPluginsDetected{hasMiniDependencyTreePlugin, dependencyTreePlugin} <- hasDependencyPlugins dir + case (projectsRes, hasMiniDependencyTreePlugin, dependencyTreePlugin) of (Nothing, _, _) -> pure ([], WalkSkipAll) - (Just projects, False, False) -> pure ([SbtTargets Nothing [] projects], WalkSkipAll) + (Just projects, False, Nothing) -> pure ([SbtTargets Nothing [] projects], WalkSkipAll) (Just projects, True, _) -> do -- project is using miniature dependency tree plugin, -- which is included by default with sbt 1.4+ @@ -184,9 +184,13 @@ findProjects = walkWithFilters' $ \dir _ files -> do (True, _) -> pure ([SbtTargets Nothing [] projects], WalkSkipAll) (_, Just _) -> pure ([SbtTargets depTreeStdOut [] projects], WalkSkipAll) (_, _) -> pure ([], WalkSkipAll) - (Just projects, False, True) -> do - -- project is explicitly configured to use dependency-tree-plugin - treeJSONs <- recover $ genTreeJson dir + (Just projects, False, Just pluginKind) -> do + -- project is explicitly configured to use dependency-tree-plugin. + -- The casing of the dependencyBrowseTree task differs between the + -- modern (sbt 1.4+) DependencyTreePlugin and the legacy + -- net.virtualvoid sbt-dependency-graph plugin; pluginKind selects + -- the right one. See TKT-15490 / ANE-2718. + treeJSONs <- recover $ genTreeJson pluginKind dir pure ([SbtTargets Nothing (fromMaybe [] treeJSONs) projects], WalkSkipAll) analyzeWithPoms :: (Has Diagnostics sig m) => ScalaProject -> m DependencyResults @@ -199,13 +203,13 @@ analyzeWithPoms (ScalaProject _ _ closure) = context "Analyzing sbt dependencies } analyzeWithDepTreeJson :: (Has ReadFS sig m, Has Diagnostics sig m) => ScalaProject -> m DependencyResults -analyzeWithDepTreeJson (ScalaProject _ treeJson closure) = context "Analyzing sbt dependencies using dependencyBrowseTreeHTML" $ do +analyzeWithDepTreeJson (ScalaProject _ treeJson closure) = context "Analyzing sbt dependencies using dependency tree JSON" $ do treeJson' <- errCtx MissingFullDependencyPluginCtx $ errHelp MissingFullDependencyPluginHelp $ errDoc sbtDepsGraphPluginUrl $ errDoc scalaFossaDocUrl $ - fromMaybeText "Could not retrieve output from sbt dependencyBrowseTreeHTML" treeJson + fromMaybeText "Could not retrieve dependency tree JSON output from sbt" treeJson projectGraph <- TreeJson.analyze treeJson' pure $ DependencyResults diff --git a/src/Strategy/Scala/Plugin.hs b/src/Strategy/Scala/Plugin.hs index d921b1781..cb514b750 100644 --- a/src/Strategy/Scala/Plugin.hs +++ b/src/Strategy/Scala/Plugin.hs @@ -4,6 +4,8 @@ module Strategy.Scala.Plugin ( hasDependencyPlugins, detectDependencyPlugins, genTreeJson, + DependencyTreePluginKind (..), + DependencyPluginsDetected (..), ) where import Control.Effect.Diagnostics (Diagnostics, fatalText) @@ -22,45 +24,102 @@ import Effect.Exec ( import Path (Abs, Dir, File, Path, mkRelFile, parent, parseAbsFile, ()) import Strategy.Scala.Common (mkSbtCommand) +-- | Which non-mini dependency-tree plugin (if any) the project has installed. +-- +-- The two plugins differ in the casing of their @dependencyBrowseTree@ task. +-- See 'mkDependencyBrowseTreeCmd' for the command names. +data DependencyTreePluginKind + = -- | @sbt.plugins.DependencyTreePlugin@. Built into sbt 1.4+ and enabled + -- explicitly via @addDependencyTreePlugin@ in @plugins.sbt@. Provides the + -- uppercase @dependencyBrowseTreeHTML@ task. + ModernDependencyTreePlugin + | -- | @net.virtualvoid.sbt.graph.DependencyGraphPlugin@. The third-party + -- @sbt-dependency-graph@ plugin used on sbt < 1.4. Provides the lowercase + -- @dependencyBrowseTreeHtml@ task. + LegacyDependencyGraphPlugin + deriving (Eq, Ord, Show) + +-- | What the @sbt plugins@ output told us about dependency-tree plugins. +data DependencyPluginsDetected = DependencyPluginsDetected + { hasMiniDependencyTreePlugin :: Bool + , dependencyTreePlugin :: Maybe DependencyTreePluginKind + } + deriving (Eq, Ord, Show) + -- | Returns list of plugins used by sbt. -- Ref: https://www.scala-sbt.org/1.x/docs/Plugins.html getPlugins :: Command getPlugins = mkSbtCommand "plugins" --- | Returns (hasMiniDependencyTreePlugin, hasDependencyTreePlugin) by running sbt plugins. -hasDependencyPlugins :: (Has Exec sig m, Has Diagnostics sig m) => Path Abs Dir -> m (Bool, Bool) +-- | Detect which dependency-tree plugins are loaded by running @sbt plugins@. +hasDependencyPlugins :: (Has Exec sig m, Has Diagnostics sig m) => Path Abs Dir -> m DependencyPluginsDetected hasDependencyPlugins projectDir = do stdoutText <- (TextLazy.toStrict . decodeUtf8) <$> context "Identifying plugins" (execThrow projectDir getPlugins) pure $ detectDependencyPlugins stdoutText --- | Detect dependency plugins from sbt plugins output. --- Returns (hasMiniDependencyTreePlugin, hasDependencyTreePlugin). -detectDependencyPlugins :: Text -> (Bool, Bool) +-- | Classify dependency-tree plugins from the @sbt plugins@ output. +-- +-- The plugin names mapped here: +-- +-- * @sbt.plugins.MiniDependencyTreePlugin@ — bundled with sbt 1.4+, gives +-- us the @dependencyTree@ task used by 'Strategy.Scala.SbtDependencyTree'. +-- * @sbt.plugins.DependencyTreePlugin@ — opt-in on sbt 1.4+ via +-- @addDependencyTreePlugin@. Provides the uppercase +-- @dependencyBrowseTreeHTML@ task. +-- * @net.virtualvoid.sbt.graph.DependencyGraphPlugin@ — third-party plugin +-- used on sbt < 1.4. Provides the lowercase @dependencyBrowseTreeHtml@ +-- task. +-- +-- Detection anchors on the @\: enabled in@ suffix rather than the bare +-- FQCN. @sbt plugins@ lists plugins the user has explicitly disabled (via +-- @disablePlugins(...)@) with @: disabled in \@ — those still +-- contain the FQCN as a substring, so an unanchored match would wrongly +-- route to a task that doesn't exist on the active plugin set. +-- +-- When both modern and legacy non-mini plugins are present we prefer the +-- modern one (sbt 1.4+ wins) since legacy plugin presence on a modern sbt +-- typically means the user has both kinds of declarations in their build. +detectDependencyPlugins :: Text -> DependencyPluginsDetected detectDependencyPlugins stdoutText = - ( Text.count ".MiniDependencyTreePlugin" stdoutText > 0 - , Text.count ".DependencyTreePlugin" stdoutText > 0 - || Text.count "net.virtualvoid.sbt.graph.DependencyGraphPlugin" stdoutText > 0 -- sbt < 1.4 - ) + DependencyPluginsDetected + { hasMiniDependencyTreePlugin = "sbt.plugins.MiniDependencyTreePlugin: enabled in" `Text.isInfixOf` stdoutText + , dependencyTreePlugin = + if "sbt.plugins.DependencyTreePlugin: enabled in" `Text.isInfixOf` stdoutText + then Just ModernDependencyTreePlugin + else + if "net.virtualvoid.sbt.graph.DependencyGraphPlugin: enabled in" `Text.isInfixOf` stdoutText + then Just LegacyDependencyGraphPlugin + else Nothing + } --- | Generates Dependency Trees. --- Ref: https://github.com/sbt/sbt/blob/master/main/src/main/scala/sbt/plugins/DependencyTreeSettings.scala#L101 --- --- This command unlike 'dependencyBrowseTree', does not open --- the browser when executed. +-- | The sbt task that writes @tree.html@/@tree.json@ alongside its dependency +-- output. Plugin name vs task casing: -- --- It writes following files in target directory: --- ./tree.json --- ./tree.html --- ./tree.data.js +-- * 'ModernDependencyTreePlugin' (sbt 1.4+, @addDependencyTreePlugin@) → +-- @dependencyBrowseTreeHTML@. +-- * 'LegacyDependencyGraphPlugin' (sbt < 1.4, @sbt-dependency-graph@) → +-- @dependencyBrowseTreeHtml@. -- --- This command is only used when the plugin is installed explicitly, i.e. sbt < 1.4. --- Newer versions of sbt will use the built-in dependency graph plugin. -mkDependencyBrowseTreeHTMLCmd :: Command -mkDependencyBrowseTreeHTMLCmd = mkSbtCommand "dependencyBrowseTreeHtml" +-- Picking the wrong casing produces an sbt error like +-- @[error] Not a valid command: dependencyBrowseTreeHTML@ which surfaces to +-- the user as "Could not retrieve output from sbt dependencyBrowseTreeHTML". +-- That regression (CLI 3.8.30) is tracked under TKT-15490 / ANE-2718. +mkDependencyBrowseTreeCmd :: DependencyTreePluginKind -> Command +mkDependencyBrowseTreeCmd ModernDependencyTreePlugin = mkSbtCommand "dependencyBrowseTreeHTML" +mkDependencyBrowseTreeCmd LegacyDependencyGraphPlugin = mkSbtCommand "dependencyBrowseTreeHtml" -genTreeJson :: (Has Exec sig m, Has Diagnostics sig m) => Path Abs Dir -> m [Path Abs File] -genTreeJson projectDir = do - stdoutBL <- context "Generating dependency tree html" $ execThrow projectDir mkDependencyBrowseTreeHTMLCmd +-- | Generates dependency trees by invoking the appropriate +-- @dependencyBrowseTree*@ task. Unlike @dependencyBrowseTree@, this does not +-- open a browser when executed. +-- +-- It writes the following files in the target directory: +-- +-- * @./tree.json@ +-- * @./tree.html@ +-- * @./tree.data.js@ +genTreeJson :: (Has Exec sig m, Has Diagnostics sig m) => DependencyTreePluginKind -> Path Abs Dir -> m [Path Abs File] +genTreeJson pluginKind projectDir = do + stdoutBL <- context "Generating dependency tree html" $ execThrow projectDir (mkDependencyBrowseTreeCmd pluginKind) -- stdout for "sbt dependencyBrowseTreeHTML" looks like: -- - diff --git a/test/Scala/PluginSpec.hs b/test/Scala/PluginSpec.hs index 61cf94447..0c65c3dbe 100644 --- a/test/Scala/PluginSpec.hs +++ b/test/Scala/PluginSpec.hs @@ -5,7 +5,11 @@ module Scala.PluginSpec ( ) where import Data.Text (Text) -import Strategy.Scala.Plugin (detectDependencyPlugins) +import Strategy.Scala.Plugin ( + DependencyPluginsDetected (..), + DependencyTreePluginKind (..), + detectDependencyPlugins, + ) import Test.Hspec ( Spec, describe, @@ -18,20 +22,54 @@ spec :: Spec spec = do describe "detectDependencyPlugins" $ do it "should detect MiniDependencyTreePlugin (sbt 1.4+ built-in)" $ do - detectDependencyPlugins sbt14BuiltinOnly `shouldBe` (True, False) + detectDependencyPlugins sbt14BuiltinOnly + `shouldBe` DependencyPluginsDetected{hasMiniDependencyTreePlugin = True, dependencyTreePlugin = Nothing} - it "should detect explicit DependencyTreePlugin" $ do - detectDependencyPlugins sbtExplicitPluginOnly `shouldBe` (False, True) + it "should detect explicit modern DependencyTreePlugin (sbt 1.4+ addDependencyTreePlugin)" $ do + detectDependencyPlugins sbtModernExplicitPluginOnly + `shouldBe` DependencyPluginsDetected{hasMiniDependencyTreePlugin = False, dependencyTreePlugin = Just ModernDependencyTreePlugin} - it "should detect legacy net.virtualvoid plugin" $ do - detectDependencyPlugins sbtLegacyVirtualvoidPlugin `shouldBe` (False, True) + it "should detect legacy net.virtualvoid plugin (sbt < 1.4 sbt-dependency-graph)" $ do + detectDependencyPlugins sbtLegacyVirtualvoidPlugin + `shouldBe` DependencyPluginsDetected{hasMiniDependencyTreePlugin = False, dependencyTreePlugin = Just LegacyDependencyGraphPlugin} -- TKT-14742: When both plugins present, findProjects should prefer MiniDependencyTreePlugin - it "should detect both plugins when MiniDependencyTreePlugin AND explicit plugin present" $ do - detectDependencyPlugins sbt14WithExplicitPlugin `shouldBe` (True, True) + it "should detect both plugins when MiniDependencyTreePlugin AND modern explicit plugin present" $ do + detectDependencyPlugins sbt14WithExplicitPlugin + `shouldBe` DependencyPluginsDetected{hasMiniDependencyTreePlugin = True, dependencyTreePlugin = Just ModernDependencyTreePlugin} + + -- TKT-15490: sbt 1.11.5 with addDependencyTreePlugin and no auto-loaded + -- MiniDependencyTreePlugin must be classified as ModernDependencyTreePlugin + -- so the analyzer runs the uppercase `dependencyBrowseTreeHTML` task. The + -- pre-fix code returned (False, True) and the routing dispatched to the + -- legacy lowercase `dependencyBrowseTreeHtml`, which sbt 1.4+ rejects. + it "should classify modern DependencyTreePlugin alone as Modern (TKT-15490 routing guard)" $ do + let detected = detectDependencyPlugins sbt111ExplicitPluginOnly + hasMiniDependencyTreePlugin detected `shouldBe` False + dependencyTreePlugin detected `shouldBe` Just ModernDependencyTreePlugin + + -- If a project somehow lists both the modern and legacy plugin, prefer + -- the modern one — sbt 1.4+ wins, since the legacy plugin will not + -- function on a sbt that also surfaces sbt.plugins.DependencyTreePlugin. + it "should prefer modern DependencyTreePlugin when both modern and legacy are present" $ do + detectDependencyPlugins sbtBothModernAndLegacy + `shouldBe` DependencyPluginsDetected{hasMiniDependencyTreePlugin = False, dependencyTreePlugin = Just ModernDependencyTreePlugin} it "should detect no plugins when neither is present" $ do - detectDependencyPlugins sbtNoPlugins `shouldBe` (False, False) + detectDependencyPlugins sbtNoPlugins + `shouldBe` DependencyPluginsDetected{hasMiniDependencyTreePlugin = False, dependencyTreePlugin = Nothing} + + -- `sbt plugins` lists user-disabled plugins (`disablePlugins(...)`) with a + -- ": disabled in " suffix. The FQCN still appears on those lines, + -- so detection must anchor on ": enabled in" to avoid routing to a task + -- the active plugin set doesn't provide. + it "should not treat MiniDependencyTreePlugin as present when listed as disabled" $ do + detectDependencyPlugins sbtDisabledMiniPlugin + `shouldBe` DependencyPluginsDetected{hasMiniDependencyTreePlugin = False, dependencyTreePlugin = Nothing} + + it "should not treat modern DependencyTreePlugin as present when listed as disabled" $ do + detectDependencyPlugins sbtDisabledModernPlugin + `shouldBe` DependencyPluginsDetected{hasMiniDependencyTreePlugin = False, dependencyTreePlugin = Nothing} -- sbt 1.4+ with only built-in plugin sbt14BuiltinOnly :: Text @@ -49,9 +87,10 @@ sbt14BuiltinOnly = [info] sbt.plugins.SemanticdbPlugin: enabled in root |] --- sbt < 1.4 with explicit addDependencyTreePlugin -sbtExplicitPluginOnly :: Text -sbtExplicitPluginOnly = +-- sbt 1.4+ with explicit addDependencyTreePlugin and no MiniDependencyTreePlugin +-- listed (the case the customer in TKT-15490 hit on sbt 1.11.5). +sbtModernExplicitPluginOnly :: Text +sbtModernExplicitPluginOnly = [r|[info] welcome to sbt 1.3.13 (Eclipse Adoptium Java 11.0.21) [info] loading global plugins from /Users/test/.sbt/1.0/plugins [info] loading project definition from /Users/test/project/project @@ -64,6 +103,24 @@ sbtExplicitPluginOnly = [info] sbt.plugins.DependencyTreePlugin: enabled in root |] +-- sbt 1.11.5 with addDependencyTreePlugin in plugins.sbt — mirrors the +-- customer environment from TKT-15490. The customer reported that the +-- pre-fix CLI invoked the lowercase `dependencyBrowseTreeHtml`, which sbt +-- 1.4+ rejects. +sbt111ExplicitPluginOnly :: Text +sbt111ExplicitPluginOnly = + [r|[info] welcome to sbt 1.11.5 (Eclipse Adoptium Java 17.0.10) +[info] loading global plugins from /Users/test/.sbt/1.0/plugins +[info] loading project definition from /Users/test/project/project +[info] loading settings for project root from build.sbt ... +[info] set current project to test-project (in build file:/Users/test/project/) +[info] In file:/Users/test/project/ +[info] sbt.plugins.CorePlugin: enabled in root +[info] sbt.plugins.IvyPlugin: enabled in root +[info] sbt.plugins.JvmPlugin: enabled in root +[info] sbt.plugins.DependencyTreePlugin: enabled in root +|] + -- sbt < 1.4 with legacy net.virtualvoid plugin sbtLegacyVirtualvoidPlugin :: Text sbtLegacyVirtualvoidPlugin = @@ -96,6 +153,18 @@ sbt14WithExplicitPlugin = [info] sbt.plugins.SemanticdbPlugin: enabled in root |] +-- A pathological setup that lists both modern and legacy plugins. +sbtBothModernAndLegacy :: Text +sbtBothModernAndLegacy = + [r|[info] welcome to sbt 1.9.7 (Eclipse Adoptium Java 11.0.21) +[info] In file:/Users/test/project/ +[info] sbt.plugins.CorePlugin: enabled in root +[info] sbt.plugins.IvyPlugin: enabled in root +[info] sbt.plugins.JvmPlugin: enabled in root +[info] sbt.plugins.DependencyTreePlugin: enabled in root +[info] net.virtualvoid.sbt.graph.DependencyGraphPlugin: enabled in root +|] + sbtNoPlugins :: Text sbtNoPlugins = [r|[info] welcome to sbt 1.9.7 (Eclipse Adoptium Java 11.0.21) @@ -108,3 +177,28 @@ sbtNoPlugins = [info] sbt.plugins.IvyPlugin: enabled in root [info] sbt.plugins.JvmPlugin: enabled in root |] + +-- User-disabled MiniDependencyTreePlugin (e.g. `disablePlugins(MiniDependencyTreePlugin)` +-- in build.sbt). The FQCN appears on a ": disabled in" line — detection +-- must reject it. +sbtDisabledMiniPlugin :: Text +sbtDisabledMiniPlugin = + [r|[info] welcome to sbt 1.9.7 (Eclipse Adoptium Java 11.0.21) +[info] In file:/Users/test/project/ +[info] sbt.plugins.CorePlugin: enabled in root +[info] sbt.plugins.IvyPlugin: enabled in root +[info] sbt.plugins.JvmPlugin: enabled in root +[info] sbt.plugins.MiniDependencyTreePlugin: disabled in root +|] + +-- User-disabled modern DependencyTreePlugin. Routing to the uppercase task +-- would fail because the plugin isn't active. +sbtDisabledModernPlugin :: Text +sbtDisabledModernPlugin = + [r|[info] welcome to sbt 1.11.5 (Eclipse Adoptium Java 17.0.10) +[info] In file:/Users/test/project/ +[info] sbt.plugins.CorePlugin: enabled in root +[info] sbt.plugins.IvyPlugin: enabled in root +[info] sbt.plugins.JvmPlugin: enabled in root +[info] sbt.plugins.DependencyTreePlugin: disabled in root +|]