diff --git a/Changelog.md b/Changelog.md index 08a06be75..d707cc0a0 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,6 +2,8 @@ ## Unreleased +- Pnpm: workspace members are now individual build targets, so a single member can be analyzed on its own with `--only-target 'pnpm@./:my-package'` or a `target:` entry in `.fossa.yml`. Previously every member's dependencies were merged into one result with no way to scope them. Dependencies a selected member reaches through the workspace protocol (`link:` in the lockfile) are included. With no target or path filter the result is unchanged. ([#1763](https://github.com/fossas/fossa-cli/pull/1763)) +- Node: when a yarn, npm, or pnpm workspace root has no `name` in its `package.json`, discovery now warns that no build targets can be offered for the workspace and that adding a `name` enables them. Previously the targets were withheld silently. ([#1763](https://github.com/fossas/fossa-cli/pull/1763)) - 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/nodejs/npm-lockfile.md b/docs/references/strategies/languages/nodejs/npm-lockfile.md index 3d2ff4333..dc5a45497 100644 --- a/docs/references/strategies/languages/nodejs/npm-lockfile.md +++ b/docs/references/strategies/languages/nodejs/npm-lockfile.md @@ -33,6 +33,11 @@ npm@./:web When a subset of targets is selected, only those packages' dependencies are included in the analysis. +The workspace root's `package.json` needs a `name` field too. Without one, no +build targets are offered: `fossa list-targets` shows a bare `npm@./`, the +whole workspace is analyzed as a single unit, and a warning explains that adding +a `name` to the root `package.json` enables per-package targets. + When no filtering is applied, all targets are selected and all dependencies from every workspace package are included in the analysis. diff --git a/docs/references/strategies/languages/nodejs/pnpm.md b/docs/references/strategies/languages/nodejs/pnpm.md index 05d74cedf..83fb72080 100644 --- a/docs/references/strategies/languages/nodejs/pnpm.md +++ b/docs/references/strategies/languages/nodejs/pnpm.md @@ -27,7 +27,10 @@ in `pnpm-lock.yaml` to analyze the dependency graph. > 📘 Important Note > -> Anything defined in the `importers` section will be ignored. In order to scan individual targts, the workspace needs to have individual/separate lock files. +> The `importers` section is the source of direct dependencies. By default every +> importer's dependencies are merged into one result for the whole workspace. To +> report on a single workspace member, select it as a build target — see +> [Workspace Build Targets](#workspace-build-targets) below. An example is provided below: @@ -157,6 +160,63 @@ CLI will infer the package name and version using `/${dependencyName}/${dependen * Optional dependencies are included in the analysis by default. They can be ignored in FOSSA UI. * `fossa-cli` supports lockFileVersion: 4.x, 5.x, 6.x, 7.x, 8.x, and 9.x. +### Workspace Build Targets + +Each workspace member, and the workspace root, is exposed as an individual build +target. A workspace named `my-workspace` whose `pnpm-workspace.yaml` lists +`browser` and `server` produces: + +``` +pnpm@./:my-workspace +pnpm@./:browser +pnpm@./:server +``` + +A target's name is that package's `name` from its `package.json`, so a member +named `@acme/browser` is selected as `pnpm@./:@acme/browser`. Run +`fossa list-targets` to see the exact names. + +The workspace root needs a `name` too. Without one, no build targets are +offered: `fossa list-targets` shows a bare `pnpm@./`, the whole workspace is +analyzed as a single unit, and a warning explains that adding a `name` to the +root `package.json` enables per-member targets. A root that is `private: true` +can carry any name; it is never published. + +Selecting a subset reports only those members' dependencies: + +```bash +fossa analyze --only-target 'pnpm@./:browser' +``` + +The same selection in `.fossa.yml`: + +```yaml +version: 3 +targets: + only: + - type: pnpm + path: ./ + target: browser +``` + +When a selected member depends on a sibling member through the +[workspace protocol](https://pnpm.io/workspaces#workspace-protocol), pnpm records +that in the lockfile as `version: link:`. The sibling's own dependencies +are part of the selected member's result, because the selected member depends on +them. + +With no target filtering, all targets are selected and every member's +dependencies are included. + +> 📘 Note +> +> A pnpm workspace is one FOSSA project rooted at the workspace root, and stays +> one project no matter which targets are selected — build targets scope what +> that project reports rather than splitting it into several projects. Running +> `fossa analyze` from inside a member's directory is not a way to scope a scan: +> without the lockfile in scope, analysis falls back to the +> [package.json strategy](packagejson.md) and its partial graph. + ### Catalogs pnpm [catalogs](https://pnpm.io/catalogs) (introduced in pnpm 9.5) are supported. diff --git a/docs/references/strategies/languages/nodejs/yarn.md b/docs/references/strategies/languages/nodejs/yarn.md index 8340ef1d9..eace8a777 100644 --- a/docs/references/strategies/languages/nodejs/yarn.md +++ b/docs/references/strategies/languages/nodejs/yarn.md @@ -55,6 +55,11 @@ yarn@./:lib-core When a subset of targets is selected, only those packages' dependencies are included in the analysis. +The workspace root's `package.json` needs a `name` field too. Without one, no +build targets are offered: `fossa list-targets` shows a bare `yarn@./`, the +whole workspace is analyzed as a single unit, and a warning explains that adding +a `name` to the root `package.json` enables per-package targets. + When no filtering is applied, all targets are selected and all dependencies from every workspace package are included in the analysis. diff --git a/integration-test/Analysis/PnpmWorkspaceSpec.hs b/integration-test/Analysis/PnpmWorkspaceSpec.hs new file mode 100644 index 000000000..17f566c5c --- /dev/null +++ b/integration-test/Analysis/PnpmWorkspaceSpec.hs @@ -0,0 +1,105 @@ +{-# LANGUAGE TemplateHaskell #-} + +-- | End-to-end coverage for target-level dependency scoping of pnpm workspaces: +-- discovery over a vendored workspace fixture, then analysis per selected build +-- target. +module Analysis.PnpmWorkspaceSpec (spec) where + +import Analysis.FixtureUtils (FixtureEnvironment (LocalEnvironment), testRunner, withResult) +import App.Fossa.Analyze.Types (AnalyzeProject (analyzeProject)) +import App.Types (Mode (NonStrict)) +import Control.Carrier.Debug (ignoreDebug) +import Control.Carrier.Reader (runReader) +import Data.Set (Set) +import Data.Set qualified as Set +import Data.Set.NonEmpty qualified as NonEmptySet +import Data.Text (Text) +import DepTypes (Dependency (dependencyName)) +import Graphing (Graphing) +import Graphing qualified +import Path (Dir, Path, Rel, mkRelDir, ()) +import Path.IO qualified as PIO +import Test.Hspec (Spec, beforeAll, describe, it, shouldBe, shouldSatisfy) +import Types ( + BuildTarget (BuildTarget), + DependencyResults (dependencyGraph), + DiscoveredProject (projectBuildTargets, projectData, projectType), + DiscoveredProjectType (PnpmProjectType), + FoundTargets (FoundTargets, ProjectWithoutTargets), + ) + +import Strategy.Node qualified as Node + +fixtureDir :: Path Rel Dir +fixtureDir = $(mkRelDir "test/Node/testdata/pnpm-workspaces/") + +-- | Every package in the fixture, the root included, is named in its +-- package.json. A workspace root without a @name@ yields no build targets. +allTargetNames :: [Text] +allTargetNames = + [ "@fossa-test/workspace" + , "@fossa-test/browser" + , "@fossa-test/server" + , "@fossa-test/shared" + ] + +data FixtureGraphs = FixtureGraphs + { discoveredTargets :: FoundTargets + , wholeGraph :: Graphing Dependency + , rootGraph :: Graphing Dependency + , browserGraph :: Graphing Dependency + , serverGraph :: Graphing Dependency + , sharedGraph :: Graphing Dependency + } + +mkTargets :: [Text] -> FoundTargets +mkTargets = maybe ProjectWithoutTargets FoundTargets . NonEmptySet.nonEmpty . Set.fromList . map BuildTarget + +depNames :: Graphing Dependency -> Set Text +depNames = Set.fromList . map dependencyName . Graphing.vertexList + +analyzeFixture :: IO FixtureGraphs +analyzeFixture = do + currentDir <- PIO.getCurrentDir + let scanDir = currentDir fixtureDir + discovered <- testRunner (Node.discover scanDir) LocalEnvironment + withResult discovered $ \_ projects -> case projects of + [project] -> do + projectType project `shouldBe` PnpmProjectType + let analyzeWith targets = do + analyzed <- testRunner (ignoreDebug $ runReader NonStrict $ analyzeProject targets (projectData project)) LocalEnvironment + withResult analyzed $ \_ depResults -> pure (dependencyGraph depResults) + FixtureGraphs (projectBuildTargets project) + <$> analyzeWith (projectBuildTargets project) + <*> analyzeWith (mkTargets ["@fossa-test/workspace"]) + <*> analyzeWith (mkTargets ["@fossa-test/browser"]) + <*> analyzeWith (mkTargets ["@fossa-test/server"]) + <*> analyzeWith (mkTargets ["@fossa-test/shared"]) + projects' -> fail ("expected exactly one discovered project, got " <> show (length projects')) + +spec :: Spec +spec = beforeAll analyzeFixture $ + describe "pnpm workspace" $ do + it "should expose the root and each workspace member as build targets" $ \fixture -> + discoveredTargets fixture `shouldBe` mkTargets allTargetNames + + it "should report only the selected member's dependencies" $ \fixture -> do + -- left-pad reaches browser through a catalog: specifier; is-odd belongs + -- only to server, and colorjs only to the root. + depNames (browserGraph fixture) `shouldSatisfy` Set.member "left-pad" + depNames (browserGraph fixture) `shouldSatisfy` (\names -> not (any (`Set.member` names) ["is-odd", "is-number", "colorjs"])) + + depNames (serverGraph fixture) `shouldBe` Set.fromList ["is-odd", "is-number"] + depNames (rootGraph fixture) `shouldBe` Set.fromList ["colorjs"] + + it "should follow a workspace link into the sibling it names" $ \fixture -> do + -- browser depends on the shared member via `version: link:../shared`. + -- Its dependencies, and their transitives, belong in browser's result; + -- the workspace package itself is not a reportable dependency. + depNames (browserGraph fixture) `shouldBe` Set.fromList ["left-pad", "uri-js", "punycode"] + depNames (sharedGraph fixture) `shouldBe` Set.fromList ["uri-js", "punycode"] + + it "should analyze the whole workspace when all targets are selected" $ \fixture -> do + depNames (wholeGraph fixture) `shouldBe` Set.fromList ["colorjs", "left-pad", "is-odd", "is-number", "uri-js", "punycode"] + [rootGraph fixture, browserGraph fixture, serverGraph fixture, sharedGraph fixture] + `shouldSatisfy` all ((`Set.isSubsetOf` depNames (wholeGraph fixture)) . depNames) diff --git a/spectrometer.cabal b/spectrometer.cabal index e53f50724..bad6cdd83 100644 --- a/spectrometer.cabal +++ b/spectrometer.cabal @@ -790,6 +790,7 @@ test-suite integration-tests Analysis.NpmLockV3WorkspaceSpec Analysis.NugetSpec Analysis.PnpmSpec + Analysis.PnpmWorkspaceSpec Analysis.Python.PipenvSpec Analysis.Python.PoetrySpec Analysis.Python.SetuptoolsSpec diff --git a/src/Strategy/Node.hs b/src/Strategy/Node.hs index 67472f45a..7e554d8bf 100644 --- a/src/Strategy/Node.hs +++ b/src/Strategy/Node.hs @@ -10,6 +10,7 @@ module Strategy.Node ( findWorkspaceBuildTargets, extractDepListsForTargets, resolveNpmV3WorkspacePaths, + resolvePnpmImporterKeys, ) where import Algebra.Graph.AdjacencyMap qualified as AM @@ -30,13 +31,13 @@ import Control.Effect.Diagnostics ( warnOnErr, ) import Control.Effect.Reader (Reader) -import Control.Monad (void, (<=<)) +import Control.Monad (void, when, (<=<)) import Data.Glob (Glob) import Data.Glob qualified as Glob import Data.List.Extra (singleton) import Data.Map (Map, toList) import Data.Map.Strict qualified as Map -import Data.Maybe (catMaybes, isJust, mapMaybe) +import Data.Maybe (catMaybes, isJust, isNothing, mapMaybe) import Data.Set (Set) import Data.Set qualified as Set import Data.Set.NonEmpty qualified as NonEmptySet @@ -57,6 +58,8 @@ import Discovery.Walk ( ) import Effect.Logger ( Logger, + logWarn, + pretty, ) import Effect.ReadFS ( ReadFS, @@ -147,7 +150,7 @@ collectManifests = walkWithFilters' $ \_ _ files -> Just jsonFile -> pure ([Manifest jsonFile], skipJsFolders) mkProject :: - (Has Diagnostics sig m) => + (Has Diagnostics sig m, Has Logger sig m) => NodeProject -> m (DiscoveredProject NodeProject) mkProject project = do @@ -160,11 +163,24 @@ mkProject project = do -- Only expose build targets for project types whose getDeps actually -- honors them. Otherwise users see per-package targets in list-targets -- but filtering has no effect on analysis. - projectBuildTargets' = case project of - Yarn _ _ -> findWorkspaceBuildTargets graph - NPMLock _ _ -> findWorkspaceBuildTargets graph - _ -> ProjectWithoutTargets + honorsTargets = case project of + Yarn _ _ -> True + NPMLock _ _ -> True + Pnpm _ _ -> True + _ -> False + projectBuildTargets' = if honorsTargets then findWorkspaceBuildTargets graph else ProjectWithoutTargets Manifest rootManifest <- fromEitherShow $ findWorkspaceRootManifest graph + -- A workspace whose root has no name gets no targets at all (see + -- 'findWorkspaceBuildTargets'), which looks like a bug from the outside: + -- list-targets shows only the project. Say why, and what fixes it. This is + -- logged rather than raised as a diagnostic warning because discovery's + -- diagnostics are only rendered under --debug. + when (honorsTargets && hasUnnamedWorkspaceRoot graph) $ + logWarn . pretty $ + "Workspace root " + <> toText (toFilePath rootManifest) + <> " has no `name`, so its members are not offered as build targets and the whole workspace is analyzed as one unit." + <> " Add a `name` to select members individually with --only-target or targets.only in .fossa.yml." pure $ DiscoveredProject { projectType = typename @@ -175,23 +191,37 @@ mkProject project = do -- | Build targets from workspace package names (root + members). -- If the workspace graph has children (i.e., workspace members), each --- package name (including the root) becomes a 'BuildTarget'. If there --- are no workspace children (single-package project), returns --- 'ProjectWithoutTargets'. +-- package name becomes a 'BuildTarget', along with the root's own name. +-- If there are no workspace children (single-package project), or the root +-- declares no @name@, returns 'ProjectWithoutTargets'. findWorkspaceBuildTargets :: PkgJsonGraph -> FoundTargets -findWorkspaceBuildTargets graph@PkgJsonGraph{..} = +findWorkspaceBuildTargets graph = let WorkspacePackageNames childNames = findWorkspaceNames graph in if Set.null childNames then ProjectWithoutTargets - else - let rootName = findWorkspaceRootManifest graph >>= \m -> maybe (Left "no name") Right (packageName =<< Map.lookup m jsonLookup) - in case rootName of - -- If the root package.json has no name field, fall back to - -- ProjectWithoutTargets so its deps aren't silently dropped. - Left _ -> ProjectWithoutTargets - Right n -> - let allNames = Set.insert n childNames - in maybe ProjectWithoutTargets FoundTargets (NonEmptySet.nonEmpty (Set.map BuildTarget allNames)) + else case workspaceRootName graph of + -- Everything that resolves selected targets back to manifests + -- matches on the package name, so a nameless root could never be + -- selected and its dependencies would be dropped by any selection, + -- including the default of every target. Offer no targets instead; + -- 'mkProject' warns so the user knows why. + Nothing -> ProjectWithoutTargets + Just n -> + let allNames = Set.insert n childNames + in maybe ProjectWithoutTargets FoundTargets (NonEmptySet.nonEmpty (Set.map BuildTarget allNames)) + +-- | The @name@ of the workspace root's package.json, if it declares one. +workspaceRootName :: PkgJsonGraph -> Maybe Text +workspaceRootName graph@PkgJsonGraph{jsonLookup} = do + root <- either (const Nothing) Just $ findWorkspaceRootManifest graph + packageName =<< Map.lookup root jsonLookup + +-- | True when the graph has workspace members but its root declares no +-- @name@: the one shape of workspace that yields no build targets. +hasUnnamedWorkspaceRoot :: PkgJsonGraph -> Bool +hasUnnamedWorkspaceRoot graph = + let WorkspacePackageNames childNames = findWorkspaceNames graph + in not (Set.null childNames) && isNothing (workspaceRootName graph) instance AnalyzeProject NodeProject where analyzeProject = getDeps @@ -207,13 +237,13 @@ getDeps :: m DependencyResults getDeps targets (Yarn yarnLockFile graph) = analyzeYarn targets yarnLockFile graph getDeps targets (NPMLock packageLockFile graph) = analyzeNpmLock targets packageLockFile graph -getDeps _ (Pnpm pnpmLockFile _) = analyzePnpmLock pnpmLockFile +getDeps targets (Pnpm pnpmLockFile graph) = analyzePnpmLock targets pnpmLockFile graph getDeps _ (Bun bunLockFile _) = analyzeBunLock bunLockFile getDeps _ (NPM graph) = analyzeNpm graph -analyzePnpmLock :: (Has Diagnostics sig m, Has ReadFS sig m, Has Logger sig m) => Manifest -> m DependencyResults -analyzePnpmLock (Manifest pnpmLockFile) = do - result <- PnpmLock.analyze pnpmLockFile +analyzePnpmLock :: (Has Diagnostics sig m, Has ReadFS sig m, Has Logger sig m) => FoundTargets -> Manifest -> PkgJsonGraph -> m DependencyResults +analyzePnpmLock targets (Manifest pnpmLockFile) graph = do + result <- PnpmLock.analyze (resolvePnpmImporterKeys targets graph) pnpmLockFile pure $ DependencyResults result Complete [pnpmLockFile] analyzeBunLock :: (Has Diagnostics sig m, Has ReadFS sig m) => Manifest -> m DependencyResults @@ -314,29 +344,44 @@ findWorkspaceNames PkgJsonGraph{..} = -- path keys npm v3 lockfiles use: @""@ for the root, @"packages/a"@ for a -- member. 'Nothing' means no target filter, so no scoping is applied. resolveNpmV3WorkspacePaths :: FoundTargets -> PkgJsonGraph -> Maybe (Set Text) -resolveNpmV3WorkspacePaths ProjectWithoutTargets _ = Nothing -resolveNpmV3WorkspacePaths (FoundTargets targets) graph@PkgJsonGraph{..} = +resolveNpmV3WorkspacePaths = resolveWorkspacePathKeys "" + +-- | Map selected build targets (workspace package names) to the importer keys +-- @pnpm-lock.yaml@ uses: @"."@ for the root, @"packages/a"@ for a member. +-- 'Nothing' means no target filter, so no scoping is applied. +-- +-- Identical to 'resolveNpmV3WorkspacePaths' apart from how the two lockfile +-- formats spell the workspace root. +resolvePnpmImporterKeys :: FoundTargets -> PkgJsonGraph -> Maybe (Set Text) +resolvePnpmImporterKeys = resolveWorkspacePathKeys "." + +-- | Shared implementation of 'resolveNpmV3WorkspacePaths' and +-- 'resolvePnpmImporterKeys', parameterized by the key the lockfile format uses +-- for the workspace root. +resolveWorkspacePathKeys :: Text -> FoundTargets -> PkgJsonGraph -> Maybe (Set Text) +resolveWorkspacePathKeys _ ProjectWithoutTargets _ = Nothing +resolveWorkspacePathKeys rootKey (FoundTargets targets) graph@PkgJsonGraph{..} = case findWorkspaceRootManifest graph of Left _ -> Nothing - Right (Manifest rootManifest) -> + Right rootManifest -> Just . Set.fromList . map snd $ filter ((`Set.member` targetNames) . fst) namePathPairs where - rootDir = parent rootManifest + rootDir = parent $ unManifest rootManifest targetNames = Set.map unBuildTarget (NonEmptySet.toSet targets) namePathPairs :: [(Text, Text)] namePathPairs = mapMaybe - (\(Manifest m, pj) -> (,) <$> packageName pj <*> manifestToWorkspacePath m) + (\(manifest, pj) -> (,) <$> packageName pj <*> manifestToWorkspacePath (unManifest manifest)) (Map.toList jsonLookup) manifestToWorkspacePath :: Path Abs File -> Maybe Text manifestToWorkspacePath m = let manifestDir = parent m in if manifestDir == rootDir - then Just "" - else -- npm keys workspaces with forward slashes on every OS, so - -- normalize the platform separator before matching. + then Just rootKey + else -- npm and pnpm both key workspaces with forward slashes on + -- every OS, so normalize the platform separator before matching. fmap (Text.replace "\\" "/" . toText . FP.dropTrailingPathSeparator . toFilePath) (stripProperPrefix rootDir manifestDir) extractDepLists :: PkgJsonGraph -> FlatDeps diff --git a/src/Strategy/Node/Pnpm/PnpmLock.hs b/src/Strategy/Node/Pnpm/PnpmLock.hs index 8f631c716..bb2c29408 100644 --- a/src/Strategy/Node/Pnpm/PnpmLock.hs +++ b/src/Strategy/Node/Pnpm/PnpmLock.hs @@ -4,18 +4,21 @@ module Strategy.Node.Pnpm.PnpmLock ( -- * for testing buildGraph, parsePnpmLockfile, + resolveImporterKey, ) where import Control.Applicative ((<|>)) import Control.Effect.Diagnostics (Diagnostics, Has, context, errSupport, fatal) +import Control.Monad (when) import Data.Aeson.Types (Value, parseEither, parseJSON) import Data.ByteString (ByteString) import Data.Either (partitionEithers) import Data.Foldable (for_) import Data.HashMap.Strict qualified as HashMap +import Data.List (foldl') import Data.Map (Map, toList) import Data.Map qualified as Map -import Data.Maybe (fromMaybe) +import Data.Maybe (fromMaybe, mapMaybe) import Data.Set qualified as Set import Data.String.Conversion (toString, toText) import Data.Text (Text) @@ -172,8 +175,12 @@ toResolvedDependency toEnv pkgs mkPkg depName depVersion = do -- -- | Core graph-building logic shared across all lockfile versions. -buildGraphCore :: BuildGraphConfig -> PnpmLockfileBase -> Graphing Dependency -buildGraphCore BuildGraphConfig{bgcGetPkgNameVersion, bgcMkPkgKey, bgcToEnv, bgcLabelingMode, bgcSnapshotEdges, bgcCatalogs} base = +-- +-- The first argument is the set of importer keys to treat as direct-dependency +-- sources, or 'Nothing' to use every importer in the lockfile. See +-- 'scopedImporters'. +buildGraphCore :: Maybe (Set.Set Text) -> BuildGraphConfig -> PnpmLockfileBase -> Graphing Dependency +buildGraphCore selection BuildGraphConfig{bgcGetPkgNameVersion, bgcMkPkgKey, bgcToEnv, bgcLabelingMode, bgcSnapshotEdges, bgcCatalogs} base = let getPkgNameVersion = bgcGetPkgNameVersion mkPkgKey = bgcMkPkgKey toEnv = bgcToEnv @@ -182,10 +189,17 @@ buildGraphCore BuildGraphConfig{bgcGetPkgNameVersion, bgcMkPkgKey, bgcToEnv, bgc catalogs = bgcCatalogs pkgs = lockfilePackages base snapshotEdgesHM = HashMap.fromList snapshotEdges - in withoutLocalPackages . hydrateDepEnvs $ + importers = maybe (lockfileImporters base) (Map.restrictKeys (lockfileImporters base)) selection + -- Every entry in `packages` is added as a deep node below, so a scoped + -- graph would otherwise still carry the whole workspace's dependencies, + -- just with a smaller direct set. Prune to what the selected importers + -- can actually reach. Unscoped analysis skips this so its output is + -- unchanged. + pruneIfScoped = maybe id (const Graphing.pruneUnreachable) selection + in pruneIfScoped . withoutLocalPackages . hydrateDepEnvs $ run . withLabeling applyLabels $ do -- Direct dependencies from each importer (workspace package). - for_ (toList (lockfileImporters base)) $ \(_, projectImporters) -> do + for_ (toList importers) $ \(_, projectImporters) -> do for_ (Map.toList $ directDependencies projectImporters) $ \(depName, ProjectMapDepMetadata depVersion) -> let resolvedVersion = resolveCatalogVersion catalogs depName depVersion in for_ (toResolvedDependency toEnv pkgs mkPkgKey depName resolvedVersion) $ \dep -> do @@ -221,6 +235,95 @@ buildGraphCore BuildGraphConfig{bgcGetPkgNameVersion, bgcMkPkgKey, bgcToEnv, bgc for_ deepDependencies $ \(deepName, deepVersion) -> do maybe (pure ()) (edge parentDep) (toResolvedDependency toEnv pkgs mkPkgKey deepName deepVersion) +-- +-- Workspace scoping +-- + +-- | The base fields of a lockfile, whatever its version. +lockfileBaseOf :: PnpmLockfile -> PnpmLockfileBase +lockfileBaseOf (LockfileV4Or5 (PnpmLockfileV4Or5 base)) = base +lockfileBaseOf (LockfileV678 (PnpmLockfileV678 base)) = base +lockfileBaseOf (LockfileV9 v) = lockfileBase v + +-- | The importers whose direct dependencies should be graphed, given the +-- importer keys resolved from the selected build targets by +-- 'Strategy.Node.resolvePnpmImporterKeys'. +-- +-- 'Nothing' means the analysis is unscoped, which must reproduce pre-scoping +-- output exactly. That happens both when no target filter is applied and when +-- the selection turns out to cover every importer in the lockfile, which is +-- the default case where all targets are selected. +-- +-- A selection that matches no importer yields @Just Set.empty@: nothing is +-- direct, so pruning leaves an empty graph. 'analyze' warns when that happens +-- rather than quietly falling back to the whole workspace. +scopedImporters :: Maybe (Set.Set Text) -> PnpmLockfileBase -> Maybe (Set.Set Text) +scopedImporters Nothing _ = Nothing +scopedImporters (Just keys) base = + if selected == allImporters then Nothing else Just selected + where + allImporters = Map.keysSet (lockfileImporters base) + selected = expandWorkspaceLinks base (keys `Set.intersection` allImporters) + +-- | Grow a selection of importer keys to include the workspace importers that +-- those importers link to, transitively. +-- +-- pnpm records a dependency on a sibling workspace package as +-- @version: link:\@ rather than as an entry in @packages@, so +-- the sibling's own dependencies live under its importer key and nowhere else. +-- Unscoped analysis merges every importer, so those dependencies land in the +-- graph regardless of who declared them; once a selection is applied they would +-- disappear. Following the links keeps a scoped result complete. +expandWorkspaceLinks :: PnpmLockfileBase -> Set.Set Text -> Set.Set Text +expandWorkspaceLinks base = go Set.empty . Set.toList + where + importers = lockfileImporters base + + go :: Set.Set Text -> [Text] -> Set.Set Text + go seen [] = seen + go seen (key : rest) + | key `Set.member` seen = go seen rest + | otherwise = go (Set.insert key seen) (linkedFrom key <> rest) + + linkedFrom :: Text -> [Text] + linkedFrom key = case Map.lookup key importers of + Nothing -> [] + Just projectMap -> + mapMaybe (linkTarget key . version) $ + Map.elems (directDependencies projectMap) <> Map.elems (directDevDependencies projectMap) + + -- A link is only followed when it names an importer the lockfile actually + -- has; a @link:@ pointing outside the workspace resolves to nothing. + linkTarget :: Text -> Text -> Maybe Text + linkTarget fromKey ver = do + relPath <- Text.stripPrefix "link:" ver + let key = resolveImporterKey fromKey relPath + if key `Map.member` importers then Just key else Nothing + +-- | Resolve a path relative to an importer back into importer-key form: +-- forward slashes, @.@ and @..@ segments collapsed, and @"."@ for the +-- workspace root. +-- +-- >> resolveImporterKey "browser" "../server" = "server" +-- >> resolveImporterKey "apps/web" "../../libs/ui" = "libs/ui" +-- >> resolveImporterKey "browser" "../" = "." +resolveImporterKey :: Text -> Text -> Text +resolveImporterKey fromKey relPath = toKey $ foldl' step [] segments + where + segments :: [Text] + segments = + concatMap (filter (not . Text.null) . Text.splitOn "/" . Text.replace "\\" "/") [fromKey, relPath] + + -- The accumulator is in reverse order, so ".." drops its head. + step :: [Text] -> Text -> [Text] + step acc ".." = drop 1 acc + step acc "." = acc + step acc segment = segment : acc + + toKey :: [Text] -> Text + toKey [] = "." + toKey acc = Text.intercalate "/" (reverse acc) + -- -- Top-level dispatch -- @@ -228,10 +331,16 @@ buildGraphCore BuildGraphConfig{bgcGetPkgNameVersion, bgcMkPkgKey, bgcToEnv, bgc -- | Build the dependency graph, labeling direct deps with their environment -- (prod\/dev). hydrateDepEnvs then propagates those environments to all -- transitive successors. -buildGraph :: PnpmLockfile -> Graphing Dependency -buildGraph (LockfileV4Or5 (PnpmLockfileV4Or5 base)) = buildGraphCore buildGraphConfigV4or5 base -buildGraph (LockfileV678 (PnpmLockfileV678 base)) = buildGraphCore buildGraphConfigV678 base -buildGraph (LockfileV9 v) = buildGraphCore (buildGraphConfigV9 v) (lockfileBase v) +-- +-- The first argument scopes the graph to a set of workspace importer keys; see +-- 'scopedImporters'. +buildGraph :: Maybe (Set.Set Text) -> PnpmLockfile -> Graphing Dependency +buildGraph selection lockfile = case lockfile of + LockfileV4Or5 (PnpmLockfileV4Or5 base) -> withSelection buildGraphConfigV4or5 base + LockfileV678 (PnpmLockfileV678 base) -> withSelection buildGraphConfigV678 base + LockfileV9 v -> withSelection (buildGraphConfigV9 v) (lockfileBase v) + where + withSelection config base = buildGraphCore (scopedImporters selection base) config base -- | Parse the contents of a pnpm-lock.yaml file. -- @@ -251,8 +360,12 @@ parsePnpmLockfile contents = case decodeAllEither' contents of ([], []) -> Left "no YAML documents found" (errs, []) -> Left . Text.intercalate "\n" $ map toText errs -analyze :: (Has ReadFS sig m, Has Logger sig m, Has Diagnostics sig m) => Path Abs File -> m (Graphing Dependency) -analyze file = context "Analyzing Pnpm Lockfile" $ do +-- | Analyze a pnpm lockfile, optionally scoped to the given workspace importer +-- keys (@"."@ for the root, @"packages/a"@ for a member), as resolved from the +-- selected build targets by 'Strategy.Node.resolvePnpmImporterKeys'. 'Nothing' +-- means no target filter is applied, so the whole workspace is graphed. +analyze :: (Has ReadFS sig m, Has Logger sig m, Has Diagnostics sig m) => Maybe (Set.Set Text) -> Path Abs File -> m (Graphing Dependency) +analyze selectedImporters file = context "Analyzing Pnpm Lockfile" $ do pnpmLockFile <- context "Parsing pnpm-lock file" $ context ("Parsing YAML file '" <> toText (toString file) <> "'") $ do contents <- readContentsBS file @@ -271,4 +384,13 @@ analyze file = context "Analyzing Pnpm Lockfile" $ do LockfileV678 _ -> pure () LockfileV9 _ -> pure () - context "Building dependency graph" $ pure $ buildGraph pnpmLockFile + let scoped = scopedImporters selectedImporters (lockfileBaseOf pnpmLockFile) + case (selectedImporters, scoped) of + (Just keys, Just selected) -> + when (Set.null selected) . logWarn . pretty $ + "Target filter (resolved importer keys: " + <> Text.intercalate ", " (Set.toList keys) + <> ") did not match any importer in the pnpm lockfile; reporting an empty dependency graph." + _ -> pure () + + context "Building dependency graph" $ pure $ buildGraph selectedImporters pnpmLockFile diff --git a/test/Node/NodeSpec.hs b/test/Node/NodeSpec.hs index f26d65bbc..21bf93d23 100644 --- a/test/Node/NodeSpec.hs +++ b/test/Node/NodeSpec.hs @@ -14,7 +14,7 @@ import DepTypes (DepEnvironment (EnvProduction), Dependency (dependencyEnvironme import Graphing qualified import Path (Abs, Dir, Path, mkRelDir, mkRelFile, ()) import Path.IO (getCurrentDir) -import Strategy.Node (NodeProject (NPMLock), discover, extractDepListsForTargets, findWorkspaceBuildTargets, getDeps, pkgGraph, resolveNpmV3WorkspacePaths) +import Strategy.Node (NodeProject (NPMLock), discover, extractDepListsForTargets, findWorkspaceBuildTargets, getDeps, pkgGraph, resolveNpmV3WorkspacePaths, resolvePnpmImporterKeys) import Strategy.Node.PackageJson ( FlatDeps (..), Manifest (..), @@ -61,6 +61,8 @@ spec = do workspaceBuildTargetsSpec currDir extractDepListsForTargetsSpec currDir resolveNpmV3WorkspacePathsSpec currDir + resolvePnpmImporterKeysSpec currDir + unnamedWorkspaceRootSpec currDir discoveredWorkSpaceProj :: Path Abs Dir -> DiscoveredProject NodeProject discoveredWorkSpaceProj currDir = @@ -311,6 +313,53 @@ resolveNpmV3WorkspacePathsSpec currDir = describe "resolveNpmV3WorkspacePaths" $ it "resolves no paths when no target matches a manifest" $ forTargets ["does-not-exist"] `shouldBe` Just Set.empty +resolvePnpmImporterKeysSpec :: Path Abs Dir -> Spec +resolvePnpmImporterKeysSpec currDir = describe "resolvePnpmImporterKeys" $ do + let graph = workspaceGraphWithDeps currDir + forTargets names = + resolvePnpmImporterKeys + (maybe ProjectWithoutTargets FoundTargets . nonEmpty $ Set.fromList (map BuildTarget names)) + graph + + it "returns Nothing when unscoped" $ + resolvePnpmImporterKeys ProjectWithoutTargets graph `shouldBe` Nothing + + it "maps the root target to the \".\" importer key" $ + -- pnpm spells the workspace root "." where npm spells it "". + forTargets ["workspace-test"] `shouldBe` Just (Set.fromList ["."]) + + it "maps a workspace name to its root-relative importer key" $ + forTargets ["pkg-b"] `shouldBe` Just (Set.fromList ["nested/pkg-b"]) + + it "maps every selected target" $ + forTargets ["workspace-test", "pkg-a", "pkg-b"] + `shouldBe` Just (Set.fromList [".", "pkg-a", "nested/pkg-b"]) + + it "resolves no importers when no target matches a manifest" $ + forTargets ["does-not-exist"] `shouldBe` Just Set.empty + +-- | A workspace root with no @name@ cannot be selected by any target filter, +-- so no targets are offered at all and the whole workspace is analyzed as one. +-- That is what keeps the root's own dependencies from being dropped. +unnamedWorkspaceRootSpec :: Path Abs Dir -> Spec +unnamedWorkspaceRootSpec currDir = describe "workspace root without a name" $ do + let graph = unnamedRootWorkspaceGraph currDir + + it "offers no build targets" $ + findWorkspaceBuildTargets graph `shouldBe` ProjectWithoutTargets + + it "still analyzes every manifest, root included" $ + directDeps (extractDepListsForTargets (findWorkspaceBuildTargets graph) graph) + `shouldBe` applyTag @Production (Set.fromList [NodePackage "husky" "^8.0.0", NodePackage "lodash" "^4.0.0", NodePackage "express" "^4.0.0"]) + +-- | 'workspaceGraphWithDeps' with the root's @name@ field removed. +unnamedRootWorkspaceGraph :: Path Abs Dir -> PkgJsonGraph +unnamedRootWorkspaceGraph currDir = + graph{jsonLookup = Map.adjust (\pj -> pj{packageName = Nothing}) (Manifest rootManifest) (jsonLookup graph)} + where + graph = workspaceGraphWithDeps currDir + rootManifest = currDir $(mkRelFile "test/Node/testdata/workspace-test/package.json") + -- | A workspace graph with actual dependencies for testing extractDepListsForTargets. workspaceGraphWithDeps :: Path Abs Dir -> PkgJsonGraph workspaceGraphWithDeps currDir = diff --git a/test/Node/testdata/pnpm-workspaces/browser/package.json b/test/Node/testdata/pnpm-workspaces/browser/package.json new file mode 100644 index 000000000..5548756c6 --- /dev/null +++ b/test/Node/testdata/pnpm-workspaces/browser/package.json @@ -0,0 +1,8 @@ +{ + "name": "@fossa-test/browser", + "version": "1.0.0", + "dependencies": { + "left-pad": "catalog:", + "@fossa-test/shared": "workspace:*" + } +} diff --git a/test/Node/testdata/pnpm-workspaces/package.json b/test/Node/testdata/pnpm-workspaces/package.json new file mode 100644 index 000000000..2725ad8ea --- /dev/null +++ b/test/Node/testdata/pnpm-workspaces/package.json @@ -0,0 +1,8 @@ +{ + "name": "@fossa-test/workspace", + "version": "1.0.0", + "private": true, + "devDependencies": { + "colorjs": "^0.1.9" + } +} diff --git a/test/Node/testdata/pnpm-workspaces/pnpm-lock.yaml b/test/Node/testdata/pnpm-workspaces/pnpm-lock.yaml new file mode 100644 index 000000000..86f50017e --- /dev/null +++ b/test/Node/testdata/pnpm-workspaces/pnpm-lock.yaml @@ -0,0 +1,82 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +catalogs: + default: + left-pad: + specifier: 1.3.0 + version: 1.3.0 + +importers: + + .: + devDependencies: + colorjs: + specifier: ^0.1.9 + version: 0.1.9 + + browser: + dependencies: + '@fossa-test/shared': + specifier: workspace:* + version: link:../shared + left-pad: + specifier: 'catalog:' + version: 1.3.0 + + server: + dependencies: + is-odd: + specifier: 3.0.1 + version: 3.0.1 + + shared: + dependencies: + uri-js: + specifier: ^4.4.1 + version: 4.4.1 + +packages: + + colorjs@0.1.9: + resolution: {integrity: sha512-filDwoNvVLqcLB4zMmWa65rgNHW/ff26wn3/q+XpYrI9EFts6fwnLIPYGOr8G+9cECMsxfCm9a2QzXdf+imY7A==} + + is-number@6.0.0: + resolution: {integrity: sha512-Wu1VHeILBK8KAWJUAiSZQX94GmOE45Rg6/538fKwiloUu21KncEkYGPqob2oSZ5mUT73vLGrHQjKw3KMPwfDzg==} + engines: {node: '>=0.10.0'} + + is-odd@3.0.1: + resolution: {integrity: sha512-CQpnWPrDwmP1+SMHXZhtLtJv90yiyVfluGsX5iNCVkrhQtU3TQHsUWPG9wkdk9Lgd5yNpAg9jQEo90CBaXgWMA==} + engines: {node: '>=4'} + + left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + +snapshots: + + colorjs@0.1.9: {} + + is-number@6.0.0: {} + + is-odd@3.0.1: + dependencies: + is-number: 6.0.0 + + left-pad@1.3.0: {} + + punycode@2.3.1: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 diff --git a/test/Node/testdata/pnpm-workspaces/pnpm-workspace.yaml b/test/Node/testdata/pnpm-workspaces/pnpm-workspace.yaml new file mode 100644 index 000000000..711ea5246 --- /dev/null +++ b/test/Node/testdata/pnpm-workspaces/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +packages: + - 'browser' + - 'server' + - 'shared' + +catalog: + left-pad: 1.3.0 diff --git a/test/Node/testdata/pnpm-workspaces/server/package.json b/test/Node/testdata/pnpm-workspaces/server/package.json new file mode 100644 index 000000000..7990e86bf --- /dev/null +++ b/test/Node/testdata/pnpm-workspaces/server/package.json @@ -0,0 +1,7 @@ +{ + "name": "@fossa-test/server", + "version": "1.0.0", + "dependencies": { + "is-odd": "3.0.1" + } +} diff --git a/test/Node/testdata/pnpm-workspaces/shared/package.json b/test/Node/testdata/pnpm-workspaces/shared/package.json new file mode 100644 index 000000000..9476f5c16 --- /dev/null +++ b/test/Node/testdata/pnpm-workspaces/shared/package.json @@ -0,0 +1,7 @@ +{ + "name": "@fossa-test/shared", + "version": "1.0.0", + "dependencies": { + "uri-js": "^4.4.1" + } +} diff --git a/test/Pnpm/PnpmLockSpec.hs b/test/Pnpm/PnpmLockSpec.hs index e6d37cf21..36cc78c8c 100644 --- a/test/Pnpm/PnpmLockSpec.hs +++ b/test/Pnpm/PnpmLockSpec.hs @@ -17,14 +17,15 @@ import DepTypes ( ) import GraphUtil ( expectDep, + expectDeps, expectDirect, expectEdge, ) import Graphing (Graphing) import Path (Abs, File, Path, mkRelFile, ()) import Path.IO (getCurrentDir) -import Strategy.Node.Pnpm.PnpmLock (buildGraph, parsePnpmLockfile) -import Test.Hspec (Expectation, Spec, describe, expectationFailure, it, runIO) +import Strategy.Node.Pnpm.PnpmLock (buildGraph, parsePnpmLockfile, resolveImporterKey) +import Test.Hspec (Expectation, Spec, describe, expectationFailure, it, runIO, shouldBe) mkProdDep :: Text -> Dependency mkProdDep nameAtVersion = mkDep nameAtVersion (Just EnvProduction) @@ -89,10 +90,16 @@ lodash = mempty checkGraph :: Path Abs File -> (Graphing Dependency -> Spec) -> Spec -checkGraph pathToFixture buildGraphSpec = do +checkGraph = checkScopedGraph Nothing + +-- | Like 'checkGraph', but scoping the graph to a set of workspace importer +-- keys, as 'Strategy.Node.resolvePnpmImporterKeys' would from selected build +-- targets. +checkScopedGraph :: Maybe (Set.Set Text) -> Path Abs File -> (Graphing Dependency -> Spec) -> Spec +checkScopedGraph selection pathToFixture buildGraphSpec = do lockFileContents <- runIO $ BS.readFile (toString pathToFixture) case parsePnpmLockfile lockFileContents of - Right pnpmLock -> buildGraphSpec (buildGraph pnpmLock) + Right pnpmLock -> buildGraphSpec (buildGraph selection pnpmLock) Left err -> describe "pnpm-lock" $ it "should parse lockfile" (expectationFailure $ toString err) @@ -143,6 +150,112 @@ spec = do describe "works with pnpm v11 multi-document lockfile" $ checkGraph pnpmLockV11MultiDoc pnpmLockV9LocalDepSpec + -- Workspace scoping. The fixture has four importers: the root (colorjs), + -- browser (left-pad, plus a link: to shared), server (is-odd -> is-number) + -- and shared (uri-js -> punycode). + let pnpmWorkspace = currentDir $(mkRelFile "test/Node/testdata/pnpm-workspaces/pnpm-lock.yaml") + + describe "workspace scoping" $ do + describe "unscoped" $ + checkScopedGraph Nothing pnpmWorkspace $ \graph -> + it "should merge every importer's direct dependencies" $ + expectDirect + [ mkDevDep "colorjs@0.1.9" + , mkProdDep "left-pad@1.3.0" + , mkProdDep "is-odd@3.0.1" + , mkProdDep "uri-js@4.4.1" + ] + graph + + describe "scoped to one member" $ + checkScopedGraph (Just $ Set.fromList ["server"]) pnpmWorkspace $ \graph -> do + it "should keep the selected member's dependencies and their transitives" $ do + expectDirect [mkProdDep "is-odd@3.0.1"] graph + expectDep (mkProdDep "is-number@6.0.0") graph + + it "should drop every other importer's dependencies" $ + expectDeps [mkProdDep "is-odd@3.0.1", mkProdDep "is-number@6.0.0"] graph + + describe "scoped to a member that links to a sibling" $ + checkScopedGraph (Just $ Set.fromList ["browser"]) pnpmWorkspace $ \graph -> do + it "should include the linked sibling's dependencies" $ + -- browser declares `@fossa-test/shared: link:../shared`, so shared's + -- own dependencies are part of browser's result. They are reported as + -- direct because the lockfile records no per-importer provenance. + expectDirect [mkProdDep "left-pad@1.3.0", mkProdDep "uri-js@4.4.1"] graph + + it "should not emit the workspace link itself as a dependency" $ + expectDeps + [ mkProdDep "left-pad@1.3.0" + , mkProdDep "uri-js@4.4.1" + , mkProdDep "punycode@2.3.1" + ] + graph + + describe "scoped to every importer" $ + checkScopedGraph (Just $ Set.fromList [".", "browser", "server", "shared"]) pnpmWorkspace $ \graph -> + it "should match the unscoped graph" $ + expectDirect + [ mkDevDep "colorjs@0.1.9" + , mkProdDep "left-pad@1.3.0" + , mkProdDep "is-odd@3.0.1" + , mkProdDep "uri-js@4.4.1" + ] + graph + + describe "scoped to an importer the lockfile does not have" $ + checkScopedGraph (Just $ Set.fromList ["nonexistent"]) pnpmWorkspace $ \graph -> + it "should report an empty graph rather than the whole workspace" $ + expectDeps [] graph + + -- Link following over a deeper workspace, see the comment in the fixture: + -- apps/web links to libs/ui, which links to libs/core, which links back to + -- libs/ui; apps/web also links to libs/testkit from devDependencies, and + -- testkit carries a link: to a path with no importer. libs/orphan and the + -- root are linked by nobody. + let pnpmWorkspaceLinks = currentDir $(mkRelFile "test/Pnpm/testdata/pnpm-9-workspace-links/pnpm-lock.yaml") + + describe "workspace link following" $ + checkScopedGraph (Just $ Set.fromList ["apps/web"]) pnpmWorkspaceLinks $ \graph -> do + it "should follow a chain of links transitively" $ do + -- is-odd is declared only by libs/core, two links away from apps/web. + expectDep (mkProdDep "is-odd@3.0.1") graph + expectDep (mkProdDep "is-number@6.0.0") graph + + it "should terminate on a link cycle and keep both sides" $ do + -- libs/ui and libs/core link to each other. + expectDep (mkProdDep "uri-js@4.4.1") graph + expectDep (mkProdDep "is-odd@3.0.1") graph + + it "should follow a link declared under devDependencies" $ + expectDep (mkProdDep "colorjs@0.1.9") graph + + it "should include exactly the linked importers' dependencies" $ + -- The dangling link in libs/testkit resolves to nothing, and neither + -- libs/orphan's lodash nor the root's typescript is reachable. + expectDeps + [ mkProdDep "left-pad@1.3.0" + , mkProdDep "uri-js@4.4.1" + , mkProdDep "punycode@2.3.1" + , mkProdDep "is-odd@3.0.1" + , mkProdDep "is-number@6.0.0" + , mkProdDep "colorjs@0.1.9" + ] + graph + + describe "resolveImporterKey" $ do + it "should resolve a sibling link to the sibling's importer key" $ + resolveImporterKey "browser" "../shared" `shouldBe` "shared" + + it "should collapse repeated parent segments" $ + resolveImporterKey "apps/web" "../../libs/ui" `shouldBe` "libs/ui" + + it "should resolve a link back to the workspace root" $ + resolveImporterKey "browser" ".." `shouldBe` "." + + it "should resolve a link relative to the root importer" $ + resolveImporterKey "." "packages/a" `shouldBe` "packages/a" + pnpmLockGraphSpec :: Graphing Dependency -> Spec pnpmLockGraphSpec graph = do let hasEdge :: Dependency -> Dependency -> Expectation diff --git a/test/Pnpm/testdata/pnpm-9-workspace-links/pnpm-lock.yaml b/test/Pnpm/testdata/pnpm-9-workspace-links/pnpm-lock.yaml new file mode 100644 index 000000000..e6a773579 --- /dev/null +++ b/test/Pnpm/testdata/pnpm-9-workspace-links/pnpm-lock.yaml @@ -0,0 +1,119 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +# A deeper workspace for exercising link following when scoping: +# apps/web -> libs/ui -> libs/core -> libs/ui (a chain that closes into a cycle) +# apps/web -> libs/testkit (linked from devDependencies) +# libs/testkit -> ../../vendor/not-a-member (a link: to a path with no importer) +# libs/orphan (linked by nobody) + +importers: + + .: + devDependencies: + typescript: + specifier: ^5.0.0 + version: 5.4.5 + + apps/web: + dependencies: + '@ws/ui': + specifier: workspace:* + version: link:../../libs/ui + left-pad: + specifier: 1.3.0 + version: 1.3.0 + devDependencies: + '@ws/testkit': + specifier: workspace:* + version: link:../../libs/testkit + + libs/core: + dependencies: + '@ws/ui': + specifier: workspace:* + version: link:../ui + is-odd: + specifier: 3.0.1 + version: 3.0.1 + + libs/orphan: + dependencies: + lodash: + specifier: ^4.17.21 + version: 4.17.21 + + libs/testkit: + dependencies: + colorjs: + specifier: ^0.1.9 + version: 0.1.9 + vendored: + specifier: link:../../vendor/not-a-member + version: link:../../vendor/not-a-member + + libs/ui: + dependencies: + '@ws/core': + specifier: workspace:* + version: link:../core + uri-js: + specifier: ^4.4.1 + version: 4.4.1 + +packages: + + colorjs@0.1.9: + resolution: {integrity: sha512-filDwoNvVLqcLB4zMmWa65rgNHW/ff26wn3/q+XpYrI9EFts6fwnLIPYGOr8G+9cECMsxfCm9a2QzXdf+imY7A==} + + is-number@6.0.0: + resolution: {integrity: sha512-Wu1VHeILBK8KAWJUAiSZQX94GmOE45Rg6/538fKwiloUu21KncEkYGPqob2oSZ5mUT73vLGrHQjKw3KMPwfDzg==} + engines: {node: '>=0.10.0'} + + is-odd@3.0.1: + resolution: {integrity: sha512-CQpnWPrDwmP1+SMHXZhtLtJv90yiyVfluGsX5iNCVkrhQtU3TQHsUWPG9wkdk9Lgd5yNpAg9jQEo90CBaXgWMA==} + engines: {node: '>=4'} + + left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + typescript@5.4.5: + resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} + engines: {node: '>=14.17'} + hasBin: true + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + +snapshots: + + colorjs@0.1.9: {} + + is-number@6.0.0: {} + + is-odd@3.0.1: + dependencies: + is-number: 6.0.0 + + left-pad@1.3.0: {} + + lodash@4.17.21: {} + + punycode@2.3.1: {} + + typescript@5.4.5: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1