Skip to content

Commit 5cc5e9e

Browse files
authored
Cargo: Fix transitive dev-dependency classification (#1692)
1 parent e21170d commit 5cc5e9e

3 files changed

Lines changed: 222 additions & 29 deletions

File tree

Changelog.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# FOSSA CLI Changelog
22

3+
## 3.17.9
4+
5+
- Cargo: Fix transitive dev-dependency classification. Dependencies reachable only through dev-dep or build-dep roots are now correctly labeled as Development instead of Production ([#1692](https://github.com/fossas/fossa-cli/pull/1692)).
6+
37
## 3.17.8
48

59
- Vendored dependencies: archive uploads with an absolute `path` (as produced by the meta-fossa Yocto layer) no longer crash with a `permission denied` error while writing the tarball. ([#1713](https://github.com/fossas/fossa-cli/pull/1713))
@@ -33,8 +37,7 @@
3337
- Poetry: Support PEP 621 `[project].dependencies` for Poetry 2.x projects. Production dependencies declared in the standard `[project]` section are now correctly detected alongside legacy `[tool.poetry.dependencies]`. ([#1683](https://github.com/fossas/fossa-cli/pull/1683))
3438

3539
## 3.17.1
36-
37-
- Node.js: Yarn and npm workspace packages now appear as individual build targets (e.g. `yarn@./:my-package`, `npm@./:my-package`), enabling per-package dependency scoping via `.fossa.yml`.
40+
- Node.js: Yarn and npm workspace packages now appear as individual build targets (e.g. `yarn@./:my-package`, `npm@./:my-package`), enabling per-package dependency scoping via `.fossa.yml`. ([#1643](https://github.com/fossas/fossa-cli/pull/1643))
3841
- Project edit: Fix 500 error when running `fossa project edit --policy` on existing projects ([#1688](https://github.com/fossas/fossa-cli/pull/1688))
3942
- UV: Add `directory` source type to uv.lock parser, fixing parse failures on projects with local directory dependencies ([#1690](https://github.com/fossas/fossa-cli/pull/1690))
4043

src/Strategy/Cargo.hs

Lines changed: 79 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,9 @@ import Data.Foldable (for_, traverse_)
5252
import Data.Functor (void)
5353
import Data.List.NonEmpty qualified as NonEmpty
5454
import Data.Map.Strict qualified as Map
55-
import Data.Maybe (catMaybes, fromMaybe, isJust)
55+
import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing)
5656
import Data.Set (Set)
57+
import Data.Set qualified as Set
5758
import Data.String.Conversion (toString, toText)
5859
import Data.Text (Text, breakOn)
5960
import Data.Text qualified as Text
@@ -74,7 +75,6 @@ import Effect.Exec (
7475
execThrow,
7576
)
7677
import Effect.Grapher (
77-
LabeledGrapher,
7878
direct,
7979
edge,
8080
label,
@@ -485,36 +485,88 @@ toDependency emitGitBackedLocators sourceMap pkg =
485485
extractGitCommitHash sourceUrl
486486
_ -> Nothing
487487

488-
-- Possible values here are "build", "dev", and null.
489-
-- Null refers to productions, while dev and build refer to development-time dependencies
490-
-- Cargo does not differentiate test dependencies and dev dependencies,
491-
-- so we just simplify it to Development.
492-
kindToLabel :: Maybe Text.Text -> CargoLabel
493-
kindToLabel (Just _) = CargoDepKind EnvDevelopment
494-
kindToLabel Nothing = CargoDepKind EnvProduction
495-
496-
addLabel :: Has (LabeledGrapher PackageId CargoLabel) sig m => NodeDependency -> m ()
497-
addLabel dep = do
498-
let packageId = nodePkg dep
499-
traverse_ (label packageId . kindToLabel . nodeDepKind) $ nodeDepKinds dep
500-
501-
addEdge :: Has (LabeledGrapher PackageId CargoLabel) sig m => ResolveNode -> m ()
502-
addEdge node = do
503-
let parentId = resolveNodeId node
504-
for_ (resolveNodeDeps node) $ \dep -> do
505-
addLabel dep
506-
edge parentId $ nodePkg dep
507-
488+
-- A Cargo edge's kind ("build", "dev", or null) reflects the parent's manifest
489+
-- declaration, not the path taken to reach the parent. We classify each package
490+
-- by which workspace-rooted paths can reach it:
491+
--
492+
-- * Production: reachable from a workspace member via a path of null-kind
493+
-- edges only. These packages are linked into the release artifact.
494+
--
495+
-- * Development: any package reachable (via any edge) from the target of a
496+
-- non-null-kind edge. A "build" or "dev" edge marks the start of a subtree
497+
-- that never ships in the release binary, and every descendant of that
498+
-- subtree inherits Development.
499+
--
500+
-- A package can carry both labels when it's reachable by both kinds of paths.
501+
--
502+
-- We do not need a separate "dev-deps of prod-deps" case: Cargo only resolves
503+
-- dev-dependencies for workspace members, so non-workspace edges with kind
504+
-- "dev" do not appear in 'cargo metadata' output. The only non-null kind we
505+
-- see on a non-workspace edge is "build".
506+
--
507+
-- Cargo is the only strategy with per-edge kinds; others (pnpm, yarn, poetry)
508+
-- label nodes and propagate with 'hydrateDepEnvs'. That helper walks from a
509+
-- labeled node to every dependency it declares, regardless of edge kind, so
510+
-- a Production label on a workspace member would flow through a "dev" or
511+
-- "build" edge and mislabel the dev/build subtree as Production. We roll our
512+
-- own edge-filtered reachability here rather than generalize the shared helper.
508513
buildGraph :: Bool -> CargoMetadata -> Graphing Dependency
509-
-- By construction, workspace members are the root nodes in the graph.
510-
-- Use shrinkRoots to remove them and promote their direct dependencies to the
511-
-- direct dependencies we report for the project.
512514
buildGraph emitGitBackedLocators meta = shrinkRoots $
513515
run . withLabeling (toDependency emitGitBackedLocators sourceMap) $ do
514-
traverse_ direct $ metadataWorkspaceMembers meta
515-
traverse_ addEdge $ resolvedNodes $ metadataResolve meta
516+
traverse_ direct (metadataWorkspaceMembers meta)
517+
for_ nodes $ \node ->
518+
for_ (resolveNodeDeps node) $ \dep ->
519+
edge (resolveNodeId node) (nodePkg dep)
520+
for_ (Set.toList prodReachable) $ \pkg ->
521+
label pkg (CargoDepKind EnvProduction)
522+
for_ (Set.toList devReachable) $ \pkg ->
523+
label pkg (CargoDepKind EnvDevelopment)
516524
where
517525
sourceMap = buildPackageSourceMap $ metadataPackages meta
526+
nodes = resolvedNodes (metadataResolve meta)
527+
workspaceMembers = Set.fromList (metadataWorkspaceMembers meta)
528+
529+
-- These predicates are not mutually exclusive: a dep declared in both
530+
-- [dependencies] and [dev-dependencies] on the same parent carries both
531+
-- a null and a non-null kind, so the edge feeds prodAdj *and* devSeeds.
532+
isProdEdge dep = any (isNothing . nodeDepKind) (nodeDepKinds dep)
533+
isDevEdge dep = any (isJust . nodeDepKind) (nodeDepKinds dep)
534+
535+
-- Adjacency containing only edges whose parent declares the child as a
536+
-- normal dependency (at least one kind is null). Production reachability
537+
-- must only traverse these — a build or dev edge breaks the release chain.
538+
prodAdj =
539+
Map.fromList $
540+
map
541+
(\node -> (resolveNodeId node, map nodePkg . filter isProdEdge . resolveNodeDeps $ node))
542+
nodes
543+
544+
-- Every edge in the metadata graph, for Development reachability.
545+
allAdj =
546+
Map.fromList $
547+
map (\node -> (resolveNodeId node, map nodePkg (resolveNodeDeps node))) nodes
548+
549+
-- Targets of any non-null-kind edge. Each seeds a Development subtree:
550+
-- the target and all its transitive descendants are never linked into
551+
-- a release build.
552+
devSeeds =
553+
Set.fromList $
554+
map nodePkg $
555+
concatMap (filter isDevEdge . resolveNodeDeps) nodes
556+
557+
prodReachable = reachable prodAdj workspaceMembers
558+
devReachable = reachable allAdj devSeeds
559+
560+
reachable :: Map.Map PackageId [PackageId] -> Set PackageId -> Set PackageId
561+
reachable adj = go Set.empty . Set.toList
562+
where
563+
go visited [] = visited
564+
go visited (x : xs) =
565+
if Set.member x visited
566+
then go visited xs
567+
else
568+
let children = fromMaybe [] (Map.lookup x adj)
569+
in go (Set.insert x visited) (children ++ xs)
518570

519571
-- | Custom Parsec type alias
520572
type PkgSpecParser a = Parsec Void Text a

test/Cargo/MetadataSpec.hs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ spec = do
7575

7676
post1_77MetadataParseSpec
7777

78+
devDepTransitiveLabelingSpec
79+
7880
extractGitCommitHashSpec
7981

8082
gitCommitHashVersionSpec
@@ -201,6 +203,142 @@ post1_77MetadataParseSpec =
201203
expectDeps [ansiTermDep, clapDep, fooDep, barDepFallback] graph
202204
dependencyName barDepFallback `shouldBe` "bar"
203205

206+
devKind :: Text -> NodeDepKind
207+
devKind k = NodeDepKind (Just k) Nothing
208+
209+
devDepTransitiveLabelingSpec :: Test.Spec
210+
devDepTransitiveLabelingSpec =
211+
Test.describe "dev-dep transitive labeling" $ do
212+
Test.it "transitive deps of dev-deps are labeled Development" $ do
213+
let wsId = PackageId "myapp" "0.1.0" "path+file:///path/to/myapp"
214+
itoaId = mkPkgId "itoa" "1.0.18"
215+
approxId = mkPkgId "approx" "0.5.1"
216+
numTraitsId = mkPkgId "num-traits" "0.2.19"
217+
autocfgId = mkPkgId "autocfg" "1.4.0"
218+
219+
wsNode =
220+
ResolveNode
221+
wsId
222+
[ NodeDependency itoaId [nullKind]
223+
, NodeDependency approxId [devKind "dev"]
224+
]
225+
approxNode =
226+
ResolveNode
227+
approxId
228+
[ NodeDependency numTraitsId [nullKind]
229+
]
230+
numTraitsNode =
231+
ResolveNode
232+
numTraitsId
233+
[ NodeDependency autocfgId [devKind "build"]
234+
]
235+
itoaNode = ResolveNode itoaId []
236+
autocfgNode = ResolveNode autocfgId []
237+
238+
meta = CargoMetadata [] [wsId] $ Resolve [wsNode, approxNode, numTraitsNode, itoaNode, autocfgNode]
239+
graph = buildGraph False meta
240+
241+
itoaDep = mkDep "itoa" "1.0.18" CargoType [EnvProduction]
242+
approxDep = mkDep "approx" "0.5.1" CargoType [EnvDevelopment]
243+
numTraitsDep = mkDep "num-traits" "0.2.19" CargoType [EnvDevelopment]
244+
autocfgDep = mkDep "autocfg" "1.4.0" CargoType [EnvDevelopment]
245+
246+
expectDeps [itoaDep, approxDep, numTraitsDep, autocfgDep] graph
247+
expectDirect [itoaDep, approxDep] graph
248+
249+
Test.it "dep reachable from both prod and dev roots gets both labels" $ do
250+
let wsId = PackageId "myapp" "0.1.0" "path+file:///path/to/myapp"
251+
aId = mkPkgId "A" "1.0.0"
252+
bId = mkPkgId "B" "1.0.0"
253+
cId = mkPkgId "C" "1.0.0"
254+
255+
wsNode =
256+
ResolveNode
257+
wsId
258+
[ NodeDependency aId [nullKind]
259+
, NodeDependency bId [devKind "dev"]
260+
]
261+
aNode = ResolveNode aId [NodeDependency cId [nullKind]]
262+
bNode = ResolveNode bId [NodeDependency cId [nullKind]]
263+
cNode = ResolveNode cId []
264+
265+
meta = CargoMetadata [] [wsId] $ Resolve [wsNode, aNode, bNode, cNode]
266+
graph = buildGraph False meta
267+
268+
aDep = mkDep "A" "1.0.0" CargoType [EnvProduction]
269+
bDep = mkDep "B" "1.0.0" CargoType [EnvDevelopment]
270+
cDep = mkDep "C" "1.0.0" CargoType [EnvProduction, EnvDevelopment]
271+
272+
expectDeps [aDep, bDep, cDep] graph
273+
expectDirect [aDep, bDep] graph
274+
275+
Test.it "direct dep declared as both normal and dev gets both labels" $ do
276+
-- Cargo emits multiple NodeDepKinds when a dep is declared in both
277+
-- [dependencies] and [dev-dependencies] on the same package.
278+
let wsId = PackageId "myapp" "0.1.0" "path+file:///path/to/myapp"
279+
dualId = mkPkgId "dual" "1.0.0"
280+
transId = mkPkgId "trans" "1.0.0"
281+
282+
wsNode = ResolveNode wsId [NodeDependency dualId [nullKind, devKind "dev"]]
283+
dualNode = ResolveNode dualId [NodeDependency transId [nullKind]]
284+
transNode = ResolveNode transId []
285+
286+
meta = CargoMetadata [] [wsId] $ Resolve [wsNode, dualNode, transNode]
287+
graph = buildGraph False meta
288+
289+
dualDep = mkDep "dual" "1.0.0" CargoType [EnvProduction, EnvDevelopment]
290+
transDep = mkDep "trans" "1.0.0" CargoType [EnvProduction, EnvDevelopment]
291+
292+
expectDeps [dualDep, transDep] graph
293+
expectDirect [dualDep] graph
294+
295+
Test.it "build-deps of prod-deps and their transitives are Development" $ do
296+
-- Regression guard: build-script utilities (autocfg, cc, version_check,
297+
-- etc.) are declared as [build-dependencies] of normal deps. They are
298+
-- compiled during the build but never linked into the release artifact,
299+
-- so they should be labeled Development, not Production — even though
300+
-- their parent is a production dep.
301+
let wsId = PackageId "myapp" "0.1.0" "path+file:///path/to/myapp"
302+
serdeId = mkPkgId "serde" "1.0.0"
303+
autocfgId = mkPkgId "autocfg" "1.0.0"
304+
autocfgChildId = mkPkgId "autocfg-child" "1.0.0"
305+
306+
wsNode = ResolveNode wsId [NodeDependency serdeId [nullKind]]
307+
serdeNode = ResolveNode serdeId [NodeDependency autocfgId [devKind "build"]]
308+
autocfgNode = ResolveNode autocfgId [NodeDependency autocfgChildId [nullKind]]
309+
childNode = ResolveNode autocfgChildId []
310+
311+
meta = CargoMetadata [] [wsId] $ Resolve [wsNode, serdeNode, autocfgNode, childNode]
312+
graph = buildGraph False meta
313+
314+
serdeDep = mkDep "serde" "1.0.0" CargoType [EnvProduction]
315+
autocfgDep = mkDep "autocfg" "1.0.0" CargoType [EnvDevelopment]
316+
childDep = mkDep "autocfg-child" "1.0.0" CargoType [EnvDevelopment]
317+
318+
expectDeps [serdeDep, autocfgDep, childDep] graph
319+
expectDirect [serdeDep] graph
320+
321+
Test.it "dep reachable via a normal prod path and a build-edge gets both labels" $ do
322+
-- X is a direct normal dep of the workspace (Production) AND a build-dep
323+
-- of a prod dep (Development via the build-edge split). Both labels
324+
-- should be applied.
325+
let wsId = PackageId "myapp" "0.1.0" "path+file:///path/to/myapp"
326+
aId = mkPkgId "A" "1.0.0"
327+
xId = mkPkgId "X" "1.0.0"
328+
329+
wsNode = ResolveNode wsId [NodeDependency aId [nullKind], NodeDependency xId [nullKind]]
330+
aNode = ResolveNode aId [NodeDependency xId [devKind "build"]]
331+
xNode = ResolveNode xId []
332+
333+
meta = CargoMetadata [] [wsId] $ Resolve [wsNode, aNode, xNode]
334+
graph = buildGraph False meta
335+
336+
aDep = mkDep "A" "1.0.0" CargoType [EnvProduction]
337+
xDep = mkDep "X" "1.0.0" CargoType [EnvProduction, EnvDevelopment]
338+
339+
expectDeps [aDep, xDep] graph
340+
expectDirect [aDep, xDep] graph
341+
204342
extractGitCommitHashSpec :: Test.Spec
205343
extractGitCommitHashSpec =
206344
Test.describe "extractGitCommitHash" $ do

0 commit comments

Comments
 (0)