Skip to content

Commit 75a83b6

Browse files
Prune unused dependencies from manifests generated from spago.dhall files (#667)
* Add purs graph module to lib * Add tests for purs graph lib module * Add purs graph CLI command and test * Implement unused dependency check in API * Extract and test install path parser function * Add prune check to the end-to-end test. * Set minimum threshold on purs graph compiler
1 parent c8a12c8 commit 75a83b6

File tree

12 files changed

+1090
-104
lines changed

12 files changed

+1090
-104
lines changed

app/fixtures/github-packages/effect-4.0.0/bower.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"package.json"
1717
],
1818
"dependencies": {
19-
"purescript-prelude": "^6.0.0"
19+
"purescript-prelude": "^6.0.0",
20+
"purescript-type-equality": "^4.0.0"
2021
}
2122
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"name":"type-equality","version":"4.0.1","license":"BSD-3-Clause","location":{"githubOwner":"purescript","githubRepo":"purescript-type-equality"},"dependencies":{}}
Binary file not shown.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"location": {
3+
"githubOwner": "purescript",
4+
"githubRepo": "purescript-type-equality"
5+
},
6+
"published": {
7+
"4.0.1": {
8+
"bytes": 2184,
9+
"hash": "sha256-Hs9D6Y71zFi/b+qu5NSbuadUQXe5iv5iWx0226vOHUw=",
10+
"publishedTime": "2022-04-27T18:00:18.000Z",
11+
"ref": "v4.0.1"
12+
}
13+
},
14+
"unpublished": {}
15+
}

app/src/App/API.purs

+257-99
Large diffs are not rendered by default.

app/src/App/CLI/Purs.purs

+4-1
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,14 @@ data PursCommand
9696
= Version
9797
| Compile { globs :: Array FilePath }
9898
| Publish { resolutions :: FilePath }
99+
| Graph { globs :: Array FilePath }
99100

100101
printCommand :: PursCommand -> Array String
101102
printCommand = case _ of
102103
Version -> [ "--version" ]
103104
Compile { globs } -> [ "compile" ] <> globs <> [ "--json-errors" ]
104105
Publish { resolutions } -> [ "publish", "--manifest", "purs.json", "--resolutions", resolutions ]
106+
Graph { globs } -> [ "graph" ] <> globs <> [ "--json-errors" ]
105107

106108
-- | Call a specific version of the PureScript compiler
107109
callCompiler :: CompilerArgs -> Aff (Either CompilerFailure String)
@@ -118,7 +120,8 @@ callCompiler compilerArgs = do
118120
$ Version.print version
119121

120122
errorsCodec = CA.Record.object "CompilerErrors"
121-
{ errors: CA.array compilerErrorCodec }
123+
{ errors: CA.array compilerErrorCodec
124+
}
122125

123126
result <- _.result =<< Execa.execa purs (printCommand compilerArgs.command) (_ { cwd = compilerArgs.cwd })
124127
pure case result of

app/test/App/API.purs

+37-2
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import Data.Array.NonEmpty as NonEmptyArray
66
import Data.Foldable (traverse_)
77
import Data.Map as Map
88
import Data.Set as Set
9+
import Data.String as String
910
import Data.String.NonEmpty as NonEmptyString
1011
import Effect.Aff as Aff
1112
import Effect.Ref as Ref
1213
import Node.FS.Aff as FS.Aff
1314
import Node.Path as Path
1415
import Node.Process as Process
1516
import Registry.App.API as API
17+
import Registry.App.CLI.Tar as Tar
1618
import Registry.App.Effect.Env as Env
1719
import Registry.App.Effect.Log as Log
1820
import Registry.App.Effect.Pursuit as Pursuit
@@ -23,7 +25,10 @@ import Registry.Constants as Constants
2325
import Registry.Foreign.FSExtra as FS.Extra
2426
import Registry.Foreign.FastGlob as FastGlob
2527
import Registry.Foreign.Tmp as Tmp
28+
import Registry.Internal.Codec as Internal.Codec
29+
import Registry.Manifest as Manifest
2630
import Registry.PackageName as PackageName
31+
import Registry.Range as Range
2732
import Registry.Test.Assert as Assert
2833
import Registry.Test.Assert.Run as Assert.Run
2934
import Registry.Test.Utils as Utils
@@ -52,11 +57,24 @@ spec = do
5257
removeIgnoredTarballFiles
5358
copySourceFiles
5459

60+
Spec.describe "Parses installed paths" do
61+
Spec.it "Parses install path <tmp>/my-package-1.0.0/..." do
62+
tmp <- Tmp.mkTmpDir
63+
let moduleA = Path.concat [ tmp, "my-package-1.0.0", "src", "ModuleA.purs" ]
64+
case API.parseInstalledModulePath { prefix: tmp, path: moduleA } of
65+
Left err -> Assert.fail $ "Expected to parse " <> moduleA <> " but got error: " <> err
66+
Right { name, version } -> do
67+
Assert.shouldEqual name (Utils.unsafePackageName "my-package")
68+
Assert.shouldEqual version (Utils.unsafeVersion "1.0.0")
69+
FS.Extra.remove tmp
70+
5571
Spec.describe "API pipelines run correctly" $ Spec.around withCleanEnv do
56-
Spec.it "Publish" \{ workdir, index, metadata, storageDir, githubDir } -> do
72+
Spec.it "Publish a legacy-converted package with unused deps" \{ workdir, index, metadata, storageDir, githubDir } -> do
5773
let testEnv = { workdir, index, metadata, username: "jon", storage: storageDir, github: githubDir }
5874
Assert.Run.runTestEffects testEnv do
59-
-- We'll publish [email protected]
75+
-- We'll publish [email protected] from the fixtures/github-packages
76+
-- directory, which has an unnecessary dependency on 'type-equality'
77+
-- inserted into it.
6078
let
6179
name = Utils.unsafePackageName "effect"
6280
version = Utils.unsafeVersion "4.0.0"
@@ -83,11 +101,28 @@ spec = do
83101
unless (Set.member version versions) do
84102
Except.throw $ "Expected " <> formatPackageVersion name version <> " to be published to registry storage."
85103

104+
-- Let's verify the manifest does not include the unnecessary
105+
-- 'type-equality' dependency...
106+
Storage.download name version "effect-result"
107+
Tar.extract { cwd: workdir, archive: "effect-result" }
108+
Run.liftAff (readJsonFile Manifest.codec (Path.concat [ "effect-4.0.0", "purs.json" ])) >>= case _ of
109+
Left err -> Except.throw $ "Expected [email protected] to be downloaded to effect-4.0.0 with a purs.json but received error " <> err
110+
Right (Manifest manifest) -> do
111+
let expectedDeps = Map.singleton (Utils.unsafePackageName "prelude") (Utils.unsafeRange ">=6.0.0 <7.0.0")
112+
when (manifest.dependencies /= expectedDeps) do
113+
Except.throw $ String.joinWith "\n"
114+
[ "Expected [email protected] to have dependencies"
115+
, printJson (Internal.Codec.packageMap Range.codec) expectedDeps
116+
, "\nbut got"
117+
, printJson (Internal.Codec.packageMap Range.codec) manifest.dependencies
118+
]
119+
86120
-- Finally, we can verify that publishing the package again should fail
87121
-- since it already exists.
88122
Except.runExcept (API.publish CurrentPackage publishArgs) >>= case _ of
89123
Left _ -> pure unit
90124
Right _ -> Except.throw $ "Expected publishing " <> formatPackageVersion name version <> " twice to fail."
125+
91126
where
92127
withCleanEnv :: (PipelineEnv -> Aff Unit) -> Aff Unit
93128
withCleanEnv action = do

app/test/App/CLI/Purs.purs

+33-1
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@ module Test.Registry.App.CLI.Purs (spec) where
22

33
import Registry.App.Prelude
44

5+
import Data.Argonaut.Parser as Argonaut.Parser
6+
import Data.Codec.Argonaut as CA
57
import Data.Foldable (traverse_)
8+
import Data.Map as Map
69
import Node.FS.Aff as FS.Aff
710
import Node.Path as Path
811
import Registry.App.CLI.Purs (CompilerFailure(..))
912
import Registry.App.CLI.Purs as Purs
1013
import Registry.Foreign.Tmp as Tmp
14+
import Registry.PursGraph (ModuleName(..))
15+
import Registry.PursGraph as PursGraph
1116
import Registry.Test.Assert as Assert
1217
import Registry.Test.Utils as Utils
1318
import Registry.Version as Version
@@ -17,7 +22,8 @@ spec :: Spec.Spec Unit
1722
spec = do
1823
traverse_ (testVersion <<< Utils.unsafeVersion) [ "0.13.0", "0.14.0", "0.14.7", "0.15.4" ]
1924
traverse_ (testMissingVersion <<< Utils.unsafeVersion) [ "0.13.1", "0.13.7", "0.15.1", "0.12.0", "0.14.12345" ]
20-
traverse_ testCompilationError [ Just (Utils.unsafeVersion "0.13.0"), Just (Utils.unsafeVersion "0.13.8"), Just (Utils.unsafeVersion "0.14.0"), Just (Utils.unsafeVersion "0.15.0"), Nothing ]
25+
traverse_ (testCompilationError <<< map Utils.unsafeVersion) [ Just "0.13.0", Just "0.13.8", Just "0.14.0", Just "0.15.0", Nothing ]
26+
traverse_ (testGraph <<< map Utils.unsafeVersion) [ Just "0.14.0", Just "0.15.0", Nothing ]
2127
where
2228
testVersion version =
2329
Spec.it ("Calls compiler version " <> Version.print version) do
@@ -49,3 +55,29 @@ spec = do
4955
Left (CompilationError [ { position: { startLine: 1, startColumn: 1 } } ]) ->
5056
pure unit
5157
_ -> Assert.fail "Should have failed with CompilationError"
58+
59+
testGraph version =
60+
Spec.it ("Produces a graph for " <> maybe "latest" Version.print version) do
61+
tmp <- Tmp.mkTmpDir
62+
let moduleA = Path.concat [ tmp, "ModuleA.purs" ]
63+
let moduleB = Path.concat [ tmp, "ModuleB.purs" ]
64+
FS.Aff.writeTextFile UTF8 moduleA "module ModuleA where\n\nimport ModuleB\n"
65+
FS.Aff.writeTextFile UTF8 moduleB "module ModuleB where\n"
66+
result <- Purs.callCompiler { command: Purs.Graph { globs: [ moduleA, moduleB ] }, cwd: Nothing, version }
67+
case result of
68+
Left runErr -> Assert.fail $ case runErr of
69+
CompilationError errs -> Purs.printCompilerErrors errs
70+
UnknownError str -> str
71+
MissingCompiler -> "MissingCompiler"
72+
Right str -> case Argonaut.Parser.jsonParser str of
73+
Left parseErr -> Assert.fail $ "Failed to parse output as JSON: " <> parseErr
74+
Right json -> case CA.decode PursGraph.pursGraphCodec json of
75+
Left decodeErr -> Assert.fail $ "Failed to decode JSON: " <> CA.printJsonDecodeError decodeErr
76+
Right graph -> do
77+
let
78+
expected = Map.fromFoldable
79+
[ Tuple (ModuleName "ModuleA") { path: moduleA, depends: [ ModuleName "ModuleB" ] }
80+
, Tuple (ModuleName "ModuleB") { path: moduleB, depends: [] }
81+
]
82+
83+
graph `Assert.shouldEqual` expected

lib/src/Operation/Validation.purs

+17
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,23 @@ getUnresolvedDependencies (Manifest { dependencies }) resolutions =
9797
| not (Range.includes dependencyRange version) -> Just $ Right $ dependencyName /\ dependencyRange /\ version
9898
| otherwise -> Nothing
9999

100+
-- | Discovers dependencies listed in the manifest that are not actually used
101+
-- | by the solved dependencies. This should not produce an error, but it
102+
-- | indicates an over-constrained manifest.
103+
getUnusedDependencies :: Manifest -> Map PackageName Version -> Set PackageName -> Maybe (NonEmptySet PackageName)
104+
getUnusedDependencies (Manifest { dependencies }) resolutions discovered = do
105+
let
106+
-- There may be too many resolved dependencies because the manifest includes
107+
-- e.g. test dependencies, so we start by only considering resolved deps
108+
-- that are actually used.
109+
inUse = Set.filter (flip Set.member discovered) (Map.keys resolutions)
110+
111+
-- Next, we can determine which dependencies are unused by looking at the
112+
-- difference between the manifest dependencies and the resolved packages
113+
unused = Set.filter (not <<< flip Set.member inUse) (Map.keys dependencies)
114+
115+
NonEmptySet.fromSet unused
116+
100117
data TarballSizeResult
101118
= ExceedsMaximum Number
102119
| WarnPackageSize Number

lib/src/PursGraph.purs

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
-- | A module describing the output of 'purs graph' along with some helper
2+
-- | functions for working with the graph.
3+
module Registry.PursGraph where
4+
5+
import Prelude
6+
7+
import Data.Array as Array
8+
import Data.Array.NonEmpty (NonEmptyArray)
9+
import Data.Array.NonEmpty as NonEmptyArray
10+
import Data.Bifunctor (bimap)
11+
import Data.Codec.Argonaut (JsonCodec)
12+
import Data.Codec.Argonaut as CA
13+
import Data.Codec.Argonaut.Record as CA.Record
14+
import Data.Either (Either(..))
15+
import Data.Map (Map)
16+
import Data.Map as Map
17+
import Data.Maybe (Maybe(..))
18+
import Data.Newtype (class Newtype, un)
19+
import Data.Profunctor as Profunctor
20+
import Data.Set (Set)
21+
import Data.Set as Set
22+
import Data.Traversable (traverse)
23+
import Data.Tuple (Tuple(..))
24+
import Node.Path (FilePath)
25+
import Registry.Internal.Codec as Internal.Codec
26+
import Registry.PackageName (PackageName)
27+
28+
-- | A graph of the dependencies between modules, discovered by the purs
29+
-- | compiler from a set of source files.
30+
type PursGraph = Map ModuleName PursGraphNode
31+
32+
pursGraphCodec :: JsonCodec PursGraph
33+
pursGraphCodec = Internal.Codec.strMap "PursGraph" (Just <<< ModuleName) (un ModuleName) pursGraphNodeCodec
34+
35+
type PursGraphNode =
36+
{ depends :: Array ModuleName
37+
, path :: FilePath
38+
}
39+
40+
pursGraphNodeCodec :: JsonCodec PursGraphNode
41+
pursGraphNodeCodec = CA.Record.object "PursGraphNode"
42+
{ depends: CA.array moduleNameCodec
43+
, path: CA.string
44+
}
45+
46+
-- | A module name string from a 'purs graph' invocation.
47+
newtype ModuleName = ModuleName String
48+
49+
derive instance Newtype ModuleName _
50+
derive instance Eq ModuleName
51+
derive instance Ord ModuleName
52+
53+
moduleNameCodec :: JsonCodec ModuleName
54+
moduleNameCodec = Profunctor.wrapIso ModuleName CA.string
55+
56+
type AssociatedError = { module :: ModuleName, path :: FilePath, error :: String }
57+
58+
-- | Given a function to parse the `path` component of `purs graph`, associate
59+
-- | all associate all modules in the groph with their package names.
60+
associateModules :: (FilePath -> Either String PackageName) -> PursGraph -> Either (NonEmptyArray AssociatedError) (Map ModuleName PackageName)
61+
associateModules parse graph = do
62+
let
63+
parsed :: Array (Either AssociatedError (Tuple ModuleName PackageName))
64+
parsed = Map.toUnfoldableUnordered graph # map \(Tuple moduleName { path }) -> parse path # bimap
65+
(\error -> { module: moduleName, path, error })
66+
(\packageName -> Tuple moduleName packageName)
67+
68+
separated :: { errors :: Array AssociatedError, values :: Array (Tuple ModuleName PackageName) }
69+
separated = parsed # Array.foldMap case _ of
70+
Left err -> { errors: [ err ], values: [] }
71+
Right tup -> { errors: [], values: [ tup ] }
72+
73+
case NonEmptyArray.fromArray separated.errors of
74+
Nothing -> pure $ Map.fromFoldable separated.values
75+
Just errors -> Left errors
76+
77+
-- | Find direct dependencies of the given module, according to the given graph.
78+
directDependencies :: ModuleName -> PursGraph -> Maybe (Set ModuleName)
79+
directDependencies name = map (Set.fromFoldable <<< _.depends) <<< Map.lookup name
80+
81+
-- | Find all dependencies of the given module, according to the given graph.
82+
allDependencies :: ModuleName -> PursGraph -> Maybe (Set ModuleName)
83+
allDependencies start graph = map Set.fromFoldable (getDependencies start)
84+
where
85+
getDependencies name =
86+
map _.depends (Map.lookup name graph) >>= case _ of
87+
[] -> pure []
88+
directs -> do
89+
let nextDeps = map Array.concat (traverse getDependencies directs)
90+
nextDeps <> Just directs

lib/test/Registry.purs

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import Test.Registry.Operation as Test.Operation
1212
import Test.Registry.Operation.Validation as Test.Operation.Validation
1313
import Test.Registry.PackageName as Test.PackageName
1414
import Test.Registry.PackageSet as Test.PackageSet
15+
import Test.Registry.PursGraph as Test.PursGraph
1516
import Test.Registry.Range as Test.Range
1617
import Test.Registry.SSH as Test.SSH
1718
import Test.Registry.Sha256 as Test.Sha256
@@ -40,3 +41,4 @@ main = Aff.launchAff_ $ Spec.Runner.runSpec [ Spec.Reporter.consoleReporter ] do
4041
Spec.describe "ManifestIndex" Test.ManifestIndex.spec
4142
Spec.describe "Solver" Test.Solver.spec
4243
Spec.describe "Operation Validation" Test.Operation.Validation.spec
44+
Spec.describe "Purs Graph" Test.PursGraph.spec

0 commit comments

Comments
 (0)