Skip to content
Draft
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
4 changes: 4 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
@@ -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))
Expand Down
22 changes: 13 additions & 9 deletions src/Strategy/Scala.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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+
Expand All @@ -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
Expand All @@ -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
Expand Down
109 changes: 84 additions & 25 deletions src/Strategy/Scala/Plugin.hs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ module Strategy.Scala.Plugin (
hasDependencyPlugins,
detectDependencyPlugins,
genTreeJson,
DependencyTreePluginKind (..),
DependencyPluginsDetected (..),
) where

import Control.Effect.Diagnostics (Diagnostics, fatalText)
Expand All @@ -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 @\<FQCN\>: enabled in@ suffix rather than the bare
-- FQCN. @sbt plugins@ lists plugins the user has explicitly disabled (via
-- @disablePlugins(...)@) with @: disabled in \<scope\>@ — 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:
-- -
Expand Down
118 changes: 106 additions & 12 deletions test/Scala/PluginSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 <scope>" 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
Expand All @@ -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
Expand All @@ -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 =
Expand Down Expand Up @@ -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)
Expand All @@ -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
|]
Loading