diff --git a/bin/src/Main.purs b/bin/src/Main.purs index 2cfb172fa..fb2c54d06 100644 --- a/bin/src/Main.purs +++ b/bin/src/Main.purs @@ -189,6 +189,7 @@ type PublishArgs = type UpgradeArgs = { setVersion :: Maybe String + , selectedPackage :: Maybe String } data SpagoCmd a = SpagoCmd GlobalArgs (Command a) @@ -374,6 +375,7 @@ runArgsParser = Optparse.fromRecord upgradeArgsParser :: Parser UpgradeArgs upgradeArgsParser = Optparse.fromRecord { setVersion: Flags.maybeSetVersion + , selectedPackage: Flags.selectedPackage } testArgsParser :: Parser TestArgs @@ -688,7 +690,7 @@ main = do runSpago docsEnv Docs.run Upgrade args -> do setVersion <- parseSetVersion args.setVersion - { env } <- mkFetchEnv { packages: mempty, selectedPackage: Nothing, pure: false, ensureRanges: false, testDeps: false, isRepl: false, migrateConfig, offline } + { env } <- mkFetchEnv { packages: mempty, selectedPackage: args.selectedPackage, pure: false, ensureRanges: false, testDeps: false, isRepl: false, migrateConfig, offline } runSpago env (Upgrade.run { setVersion }) Auth args -> do { env } <- mkFetchEnv { packages: mempty, selectedPackage: Nothing, pure: false, ensureRanges: false, testDeps: false, isRepl: false, migrateConfig, offline } diff --git a/core/src/Config.purs b/core/src/Config.purs index ac66a706c..0543b08e0 100644 --- a/core/src/Config.purs +++ b/core/src/Config.purs @@ -18,10 +18,12 @@ module Spago.Core.Config , SetAddress(..) , StatVerbosity(..) , TestConfig + , VersionConstraint(..) , WarningCensorTest(..) , WorkspaceBuildOptionsInput , WorkspaceConfig , configCodec + , constraintToRange , dependenciesCodec , extraPackageCodec , gitPackageCodec @@ -263,7 +265,18 @@ parseBundleType = case _ of bundleTypeCodec :: CJ.Codec BundleType bundleTypeCodec = CJ.Sum.enumSum show (parseBundleType) -newtype Dependencies = Dependencies (Map PackageName (Maybe Range)) +-- | Version constraint for a dependency - either an exact version or a range +data VersionConstraint + = ExactVersion Version + | VersionRange Range + +derive instance Eq VersionConstraint + +instance Show VersionConstraint where + show (ExactVersion v) = show $ Version.print v + show (VersionRange r) = show $ Range.print r + +newtype Dependencies = Dependencies (Map PackageName (Maybe VersionConstraint)) derive instance Eq Dependencies derive instance Newtype Dependencies _ @@ -272,38 +285,48 @@ instance Semigroup Dependencies where append (Dependencies d1) (Dependencies d2) = Dependencies $ Map.unionWith ( case _, _ of Nothing, Nothing -> Nothing - Just r, Nothing -> Just r - Nothing, Just r -> Just r - Just r1, Just r2 -> Range.intersect r1 r2 + Just c, Nothing -> Just c + Nothing, Just c -> Just c + Just c1, Just c2 -> constraintIntersect c1 c2 ) d1 d2 + where + constraintIntersect c1 c2 = Range.intersect (constraintToRange' c1) (constraintToRange' c2) <#> VersionRange + constraintToRange' (ExactVersion v) = Range.exact v + constraintToRange' (VersionRange r) = r instance Monoid Dependencies where mempty = Dependencies (Map.empty) +-- | Convert a version constraint to a range (for solver compatibility) +constraintToRange :: Maybe VersionConstraint -> Range +constraintToRange Nothing = widestRange +constraintToRange (Just (ExactVersion v)) = Range.exact v +constraintToRange (Just (VersionRange r)) = r + dependenciesCodec :: CJ.Codec Dependencies dependenciesCodec = Profunctor.dimap to from $ CJ.array dependencyCodec where - packageSingletonCodec = Reg.Internal.Codec.packageMap spagoRangeCodec + packageSingletonCodec = Reg.Internal.Codec.packageMap versionConstraintCodec - to :: Dependencies -> Array (Either PackageName (Map PackageName Range)) + to :: Dependencies -> Array (Either PackageName (Map PackageName VersionConstraint)) to (Dependencies deps) = map - ( \(Tuple name maybeRange) -> case maybeRange of + ( \(Tuple name maybeConstraint) -> case maybeConstraint of Nothing -> Left name - Just r -> Right (Map.singleton name r) + Just c -> Right (Map.singleton name c) ) $ Map.toUnfoldable deps :: Array _ - from :: Array (Either PackageName (Map PackageName Range)) -> Dependencies + from :: Array (Either PackageName (Map PackageName VersionConstraint)) -> Dependencies from = Dependencies <<< Map.fromFoldable <<< map ( case _ of Left name -> Tuple name Nothing Right m -> rmap Just $ unsafeFromJust (List.head (Map.toUnfoldable m)) ) - dependencyCodec :: CJ.Codec (Either PackageName (Map PackageName Range)) + dependencyCodec :: CJ.Codec (Either PackageName (Map PackageName VersionConstraint)) dependencyCodec = Codec.codec' decode encode where encode = case _ of @@ -318,16 +341,21 @@ widestRange :: Range widestRange = Either.fromRight' (\_ -> unsafeCrashWith "Fake range failed") $ Range.parse ">=0.0.0 <2147483647.0.0" -spagoRangeCodec :: CJ.Codec Range -spagoRangeCodec = CJ.prismaticCodec "SpagoRange" rangeParse printSpagoRange CJ.string +versionConstraintCodec :: CJ.Codec VersionConstraint +versionConstraintCodec = CJ.prismaticCodec "VersionConstraint" constraintParse printConstraint CJ.string where - rangeParse str = - if str == "*" then Just widestRange - -- First try parsing as a range (e.g. ">=1.0.0 <2.0.0") + constraintParse str = + -- First check for widest range + if str == "*" then Just (VersionRange widestRange) + -- Then try parsing as a range (e.g. ">=1.0.0 <2.0.0") else case hush $ Range.parse str of - Just range -> Just range - -- Then try parsing as an exact version (e.g. "1.0.0" -> ">=1.0.0 <1.0.1") - Nothing -> Range.exact <$> hush (Version.parse str) + Just range -> Just (VersionRange range) + -- Finally try parsing as an exact version (e.g. "1.0.0") + Nothing -> ExactVersion <$> hush (Version.parse str) + + printConstraint = case _ of + ExactVersion v -> Version.print v + VersionRange r -> printSpagoRange r printSpagoRange :: Range -> String printSpagoRange range = diff --git a/src/Spago/Command/Fetch.purs b/src/Spago/Command/Fetch.purs index b4c5429db..1be5b0465 100644 --- a/src/Spago/Command/Fetch.purs +++ b/src/Spago/Command/Fetch.purs @@ -540,7 +540,7 @@ getPackageDependencies packageName package = case package of when (offline == Offline) do unlessM (FS.exists packageLocation) do die $ "Package '" <> PackageName.print packageName <> "' is not in the local cache, and Spago is running in offline mode - can't make progress." - pure $ Just { core: map (fromMaybe Config.widestRange) dependencies, test: Map.empty } + pure $ Just { core: map Config.constraintToRange dependencies, test: Map.empty } -- if the dependencies are not declared, then we need to clone the repo -- to look at the package manifest inside Nothing -> do @@ -552,7 +552,7 @@ getPackageDependencies packageName package = case package of LocalPackage p -> do readLocalDependencies $ Path.global p.path WorkspacePackage p -> - pure $ Just $ (map (fromMaybe Config.widestRange) <<< unwrap) `onEachEnv` getWorkspacePackageDeps p + pure $ Just $ (map Config.constraintToRange <<< unwrap) `onEachEnv` getWorkspacePackageDeps p where -- try to see if the package has a spago config, and if it's there we read it readLocalDependencies :: GlobalPath -> Spago (FetchEnv a) (Maybe (ByEnv (Map PackageName Range))) @@ -561,8 +561,8 @@ getPackageDependencies packageName package = case package of Config.readConfig (configLocation "spago.yaml") >>= case _ of Right { yaml: { package: Just { dependencies: Dependencies deps, test } } } -> pure $ Just - { core: fromMaybe Config.widestRange <$> deps - , test: fromMaybe Config.widestRange <$> (test <#> _.dependencies <#> unwrap # fromMaybe Map.empty) + { core: Config.constraintToRange <$> deps + , test: Config.constraintToRange <$> (test <#> _.dependencies <#> unwrap # fromMaybe Map.empty) } Right _ -> die [ "Read the configuration at path " <> Path.quote configLocation @@ -620,7 +620,7 @@ getWorkspaceTransitiveDeps = do -- | workspace is using. getTransitiveDeps :: forall a. Config.WorkspacePackage -> Spago (FetchEnv a) (ByEnv PackageMap) getTransitiveDeps workspacePackage = do - let depsRanges = (map (fromMaybe Config.widestRange) <<< unwrap) `onEachEnv` getWorkspacePackageDeps workspacePackage + let depsRanges = (map Config.constraintToRange <<< unwrap) `onEachEnv` getWorkspacePackageDeps workspacePackage { workspace } <- ask case workspace.packageSet.lockfile of -- If we have a lockfile we can compute transitive deps from the lockfile data diff --git a/src/Spago/Command/Publish.purs b/src/Spago/Command/Publish.purs index 58477124f..19863d943 100644 --- a/src/Spago/Command/Publish.purs +++ b/src/Spago/Command/Publish.purs @@ -120,7 +120,7 @@ publish _args = do $ map ( case _ of Tuple pkg Nothing -> Left pkg - Tuple pkg (Just range) -> Right (Tuple pkg range) + Tuple pkg (Just constraint) -> Right (Tuple pkg (Config.constraintToRange (Just constraint))) ) $ (Map.toUnfoldable :: Map _ _ -> Array _) $ unwrap selected.package.dependencies diff --git a/src/Spago/Command/Upgrade.purs b/src/Spago/Command/Upgrade.purs index 19631654c..36325a75a 100644 --- a/src/Spago/Command/Upgrade.purs +++ b/src/Spago/Command/Upgrade.purs @@ -2,16 +2,36 @@ module Spago.Command.Upgrade where import Spago.Prelude +import Data.Array.NonEmpty as NEA +import Data.FunctorWithIndex (mapWithIndex) +import Data.Map as Map +import Registry.PackageName as PackageName +import Registry.Range as Range import Registry.Version as Version +import Spago.Command.Build as Build import Spago.Command.Fetch (FetchEnv) +import Spago.Command.Fetch as Fetch +import Spago.Config (WorkspacePackage) import Spago.Config as Config import Spago.Core.Config as Core +import Spago.FS as FS +import Spago.Path as Path import Spago.Registry as Registry type UpgradeArgs = { setVersion :: Maybe Version } +type UpgradePlan = + { workspacePackage :: WorkspacePackage + , pkgDoc :: YamlDoc Core.Config + , currentCore :: Map PackageName (Maybe Core.VersionConstraint) + , currentTest :: Map PackageName (Maybe Core.VersionConstraint) + , upgradedCore :: Map PackageName (Maybe Core.VersionConstraint) + , upgradedTest :: Map PackageName (Maybe Core.VersionConstraint) + , resolvedVersions :: Map PackageName Version + } + run :: ∀ a. UpgradeArgs -> Spago (FetchEnv a) Unit run args = do { workspace, rootPath } <- ask @@ -30,4 +50,106 @@ run args = do Config.setPackageSetVersionInConfig rootPath doc latestPackageSet logSuccess "Upgrade successful!" Just _ -> die "This command is not yet implemented for projects using a custom package set." - Nothing -> die "This command is not yet implemented for projects using a solver. See https://github.com/purescript/spago/issues/1001" + Nothing -> do + -- Solver-based project: upgrade dependency ranges to latest compatible versions + { logOptions, git, purs } <- ask + let + extraPackages = case workspace.packageSet.buildType of + Config.RegistrySolverBuild ep -> ep + _ -> Map.empty + + let workspacePackages = Config.getWorkspacePackages workspace.packageSet + + packagesToUpgrade <- case workspace.selected of + Just wp -> pure (NEA.singleton wp) + Nothing -> pure workspacePackages + + -- (1) compute all upgrade plans + upgradePlans <- for packagesToUpgrade \workspacePackage -> do + pkgDoc <- justOrDieWith workspacePackage.doc Config.configDocMissingErrorMessage + computeUpgradePlan workspacePackage pkgDoc extraPackages + + -- (2) build all to verify upgraded dependencies work + logInfo "Building with upgraded dependencies to verify compatibility..." + + -- Construct PackageTransitiveDeps from resolved versions + let + plans = NEA.toArray upgradePlans + toPkgMap plan = plan.resolvedVersions # mapWithIndex \pkgName version -> + fromMaybe (Config.RegistryVersion version) (Map.lookup pkgName extraPackages) + + dependencies :: Fetch.PackageTransitiveDeps + dependencies = Map.fromFoldable $ plans <#> + \p -> p.workspacePackage.package.name /\ { core: toPkgMap p, test: Map.empty } + + -- Install all, construct a BuildEnv and run the build + Fetch.fetchPackagesToLocalCache (Fetch.toAllDependencies dependencies) + let + buildEnv = + { logOptions + , rootPath + , purs + , git + , dependencies + , workspace + , strictWarnings: Nothing + , pedanticPackages: false + } + buildSuccess <- runSpago buildEnv (Build.run { depsOnly: false, pursArgs: [], jsonErrors: false }) + + unless buildSuccess do + die + [ "Build failed with upgraded dependencies. Config was not modified." + , "Check the build errors above to identify incompatible packages." + ] + + -- (3) persist config changes only if there are actual upgrades + for_ plans \plan -> do + let hasChanges = plan.upgradedCore /= plan.currentCore || plan.upgradedTest /= plan.currentTest + when hasChanges do + let configPath = plan.workspacePackage.path "spago.yaml" + logInfo $ "Updating dependency ranges in " <> Path.quote configPath + unless (Map.isEmpty plan.upgradedCore) do + liftEffect $ Config.addConstraintsToConfig plan.pkgDoc plan.upgradedCore + unless (Map.isEmpty plan.upgradedTest) do + liftEffect $ Config.addTestConstraintsToConfig plan.pkgDoc plan.upgradedTest + liftAff $ FS.writeYamlDocFile configPath plan.pkgDoc + + logSuccess "Upgrade successful!" + +-- | Computes an upgrade plan for a package without persisting changes. +computeUpgradePlan :: forall a. WorkspacePackage -> YamlDoc Core.Config -> Config.PackageMap -> Spago (FetchEnv a) UpgradePlan +computeUpgradePlan workspacePackage pkgDoc extraPackages = do + -- Get current dependencies + let currentDeps = Fetch.getWorkspacePackageDeps workspacePackage + let currentCore = unwrap currentDeps.core + let currentTest = unwrap currentDeps.test + + -- Widen all constraints to * and call solver for all dependencies combined + let allWidened = map (const Core.widestRange) $ Map.union currentCore currentTest + logInfo $ "Resolving latest compatible versions for " <> PackageName.print workspacePackage.package.name <> "..." + allPlan <- Fetch.getTransitiveDepsFromRegistry allWidened extraPackages + + -- Upgrade constraints preserving their type: + -- - If solver didn't resolve the dep, keep the old constraint + -- - Nothing (bare dep) stays Nothing + -- - ExactVersion gets new exact version + -- - VersionRange gets union with new caret (widest range stays widest) + let + upgradeConstraints :: Map PackageName (Maybe Core.VersionConstraint) -> Map PackageName Version -> Map PackageName (Maybe Core.VersionConstraint) + upgradeConstraints oldDeps newVersions = mapWithIndex computeConstraint oldDeps + where + computeConstraint name maybeOldConstraint = + case Map.lookup name newVersions of + Nothing -> maybeOldConstraint -- solver didn't resolve, keep old + Just newVersion -> case maybeOldConstraint of + Nothing -> Nothing -- bare dep stays bare + Just (Core.ExactVersion _) -> Just (Core.ExactVersion newVersion) -- exact stays exact + Just (Core.VersionRange r) + | r == Core.widestRange -> Just (Core.VersionRange Core.widestRange) -- "*" stays "*" + | otherwise -> Just (Core.VersionRange (Range.union r (Range.caret newVersion))) + + upgradedCore = upgradeConstraints currentCore allPlan + upgradedTest = upgradeConstraints currentTest allPlan + + pure { workspacePackage, pkgDoc, currentCore, currentTest, upgradedCore, upgradedTest, resolvedVersions: allPlan } diff --git a/src/Spago/Config.js b/src/Spago/Config.js index 722316ed6..7631060b7 100644 --- a/src/Spago/Config.js +++ b/src/Spago/Config.js @@ -74,21 +74,46 @@ export function removePackagesFromConfigImpl(doc, isTest, shouldRemove) { deps.items = newItems; } -export function addRangesToConfigImpl(doc, rangesMap) { - const deps = doc.get("package").get("dependencies"); +// Helper to update dependency items with ranges from a map. +// +// Dependencies in spago.yaml can be either: +// - A scalar (bare package name): `- prelude` +// - A map (name + range/version): `- prelude: ">=6.0.0 <7.0.0"` +// +// This function updates dependencies with ranges from rangesMap: +// - Scalars are converted to maps if a range is found +// - Existing maps are updated with the new range if one is provided +// - Dependencies not in rangesMap are preserved unchanged +function updateDepsWithRanges(doc, deps, rangesMap) { + if (!deps || !deps.items) return; - // if a dependency is an object then we know it has a range, otherwise we - // look up in the map of ranges and add it from there. let newItems = []; for (const el of deps.items) { - // If it's not a scalar then we have a version range, let it be if (Yaml.isMap(el)) { - newItems.push(el); - } - if (Yaml.isScalar(el)) { - let newEl = new Map(); - newEl.set(el.value, rangesMap[el.value]); - newItems.push(doc.createNode(newEl)); + // Already has a version range - check if we have an updated range + const packageName = el.items[0].key.value; + const range = rangesMap[packageName]; + if (range) { + // Update with new range + let newEl = new Map(); + newEl.set(packageName, range); + newItems.push(doc.createNode(newEl)); + } else { + // No update provided, keep unchanged + newItems.push(el); + } + } else if (Yaml.isScalar(el)) { + // Bare package name - look up range in the map + const range = rangesMap[el.value]; + if (range) { + // Found a range, convert scalar to map format + let newEl = new Map(); + newEl.set(el.value, range); + newItems.push(doc.createNode(newEl)); + } else { + // No range provided for this package, keep the original scalar. + newItems.push(el); + } } } @@ -96,6 +121,24 @@ export function addRangesToConfigImpl(doc, rangesMap) { deps.items = newItems; } +// Add version ranges to package.dependencies +export function addRangesToConfigImpl(doc, rangesMap) { + const deps = doc.get("package").get("dependencies"); + updateDepsWithRanges(doc, deps, rangesMap); +} + +// Add version ranges to package.test.dependencies. +export function addTestRangesToConfigImpl(doc, rangesMap) { + const test = doc.get("package").get("test"); + // in the above variant we don't test for the existence of dependencies, because + // they must always exist. We are not guaranteed the existence of the test section, + // so we need an additional check here + if (!test) return; + + const deps = test.get("dependencies"); + updateDepsWithRanges(doc, deps, rangesMap); +} + // Note: this function assumes a few things: // - the `publish` section exists // - the new element does not already exist in the list (it just appends it) diff --git a/src/Spago/Config.purs b/src/Spago/Config.purs index 96e3640bb..e836f91b0 100644 --- a/src/Spago/Config.purs +++ b/src/Spago/Config.purs @@ -7,10 +7,13 @@ module Spago.Config , Workspace , WorkspaceBuildOptions , WorkspacePackage + , addConstraintsToConfig , addOwner , addPackagesToConfig , addPublishLocationToConfig , addRangesToConfig + , addTestConstraintsToConfig + , addTestRangesToConfig , configDocMissingErrorMessage , fileSystemCharEscape , getLocalPackageLocation @@ -782,12 +785,37 @@ addPackagesToConfig configPath doc isTest pkgs = do removePackagesFromConfig :: YamlDoc Core.Config -> Boolean -> NonEmptySet PackageName -> Effect Unit removePackagesFromConfig doc isTest pkgs = runEffectFn3 removePackagesFromConfigImpl doc isTest (flip NonEmptySet.member pkgs) -addRangesToConfig :: YamlDoc Core.Config -> Map PackageName Range -> Effect Unit -addRangesToConfig doc = runEffectFn2 addRangesToConfigImpl doc - <<< Foreign.fromFoldable +-- | Convert a map of package ranges to the format expected by the FFI +rangesToForeignObject :: Map PackageName Range -> Foreign.Object String +rangesToForeignObject = Foreign.fromFoldable <<< map (\(Tuple name range) -> Tuple (PackageName.print name) (Core.printSpagoRange range)) <<< (Map.toUnfoldable :: Map _ _ -> Array _) +addRangesToConfig :: YamlDoc Core.Config -> Map PackageName Range -> Effect Unit +addRangesToConfig doc = runEffectFn2 addRangesToConfigImpl doc <<< rangesToForeignObject + +addTestRangesToConfig :: YamlDoc Core.Config -> Map PackageName Range -> Effect Unit +addTestRangesToConfig doc = runEffectFn2 addTestRangesToConfigImpl doc <<< rangesToForeignObject + +-- | Convert a map of version constraints to the format expected by addRangesToConfig. +-- | Nothing values (bare deps) are filtered out - they stay unchanged in the YAML. +-- | Just values are printed to strings. +constraintsToRangesObject :: Map PackageName (Maybe Core.VersionConstraint) -> Foreign.Object String +constraintsToRangesObject = Foreign.fromFoldable + <<< Array.mapMaybe (\(Tuple name maybeConstraint) -> Tuple (PackageName.print name) <$> (printConstraint <$> maybeConstraint)) + <<< (Map.toUnfoldable :: Map _ _ -> Array _) + where + printConstraint (Core.ExactVersion v) = Version.print v + printConstraint (Core.VersionRange r) = Core.printSpagoRange r + +-- | Update constraints in package.dependencies. Nothing values are left unchanged. +addConstraintsToConfig :: YamlDoc Core.Config -> Map PackageName (Maybe Core.VersionConstraint) -> Effect Unit +addConstraintsToConfig doc = runEffectFn2 addRangesToConfigImpl doc <<< constraintsToRangesObject + +-- | Update constraints in package.test.dependencies. Nothing values are left unchanged. +addTestConstraintsToConfig :: YamlDoc Core.Config -> Map PackageName (Maybe Core.VersionConstraint) -> Effect Unit +addTestConstraintsToConfig doc = runEffectFn2 addTestRangesToConfigImpl doc <<< constraintsToRangesObject + configDocMissingErrorMessage :: String configDocMissingErrorMessage = Array.fold [ "This operation requires a YAML config document, but none was found in the environment. " @@ -809,6 +837,7 @@ foreign import setPackageSetVersionInConfigImpl :: EffectFn2 (YamlDoc Core.Confi foreign import addPackagesToConfigImpl :: EffectFn3 (YamlDoc Core.Config) Boolean (Array String) Unit foreign import removePackagesFromConfigImpl :: EffectFn3 (YamlDoc Core.Config) Boolean (PackageName -> Boolean) Unit foreign import addRangesToConfigImpl :: EffectFn2 (YamlDoc Core.Config) (Foreign.Object String) Unit +foreign import addTestRangesToConfigImpl :: EffectFn2 (YamlDoc Core.Config) (Foreign.Object String) Unit foreign import addPublishLocationToConfigImpl :: EffectFn2 (YamlDoc Core.Config) JSON Unit foreign import addOwnerImpl :: EffectFn2 (YamlDoc Core.Config) OwnerJS Unit foreign import migrateV1ConfigImpl :: forall a. YamlDoc a -> Nullable (YamlDoc Core.Config) diff --git a/test-fixtures/upgrade/solver-empty-deps/spago.yaml b/test-fixtures/upgrade/solver-empty-deps/spago.yaml new file mode 100644 index 000000000..f8081dabe --- /dev/null +++ b/test-fixtures/upgrade/solver-empty-deps/spago.yaml @@ -0,0 +1,6 @@ +package: + name: empty-deps + dependencies: [] + +workspace: + extraPackages: {} diff --git a/test-fixtures/upgrade/solver-empty-deps/src/Main.purs b/test-fixtures/upgrade/solver-empty-deps/src/Main.purs new file mode 100644 index 000000000..21121d67c --- /dev/null +++ b/test-fixtures/upgrade/solver-empty-deps/src/Main.purs @@ -0,0 +1,4 @@ +module Main where + +-- No dependencies needed +data Unit = Unit diff --git a/test-fixtures/upgrade/solver-workspace-only/spago.yaml b/test-fixtures/upgrade/solver-workspace-only/spago.yaml new file mode 100644 index 000000000..e49ed7f86 --- /dev/null +++ b/test-fixtures/upgrade/solver-workspace-only/spago.yaml @@ -0,0 +1,2 @@ +workspace: + extraPackages: {} diff --git a/test-fixtures/upgrade/solver-workspace-only/subpkg/spago.yaml b/test-fixtures/upgrade/solver-workspace-only/subpkg/spago.yaml new file mode 100644 index 000000000..8823a8dd1 --- /dev/null +++ b/test-fixtures/upgrade/solver-workspace-only/subpkg/spago.yaml @@ -0,0 +1,6 @@ +package: + name: subpkg + dependencies: + - prelude: ">=6.0.0 <6.0.1" + - effect: ">=4.0.0 <5.0.0" + - console: ">=6.0.0 <7.0.0" diff --git a/test-fixtures/upgrade/solver-workspace-only/subpkg/src/Main.purs b/test-fixtures/upgrade/solver-workspace-only/subpkg/src/Main.purs new file mode 100644 index 000000000..a701ccb20 --- /dev/null +++ b/test-fixtures/upgrade/solver-workspace-only/subpkg/src/Main.purs @@ -0,0 +1,8 @@ +module Main where + +import Prelude +import Effect (Effect) +import Effect.Console (log) + +main :: Effect Unit +main = log "Hello" diff --git a/test-fixtures/upgrade/solver-workspace-only/subpkg2/spago.yaml b/test-fixtures/upgrade/solver-workspace-only/subpkg2/spago.yaml new file mode 100644 index 000000000..719b45513 --- /dev/null +++ b/test-fixtures/upgrade/solver-workspace-only/subpkg2/spago.yaml @@ -0,0 +1,4 @@ +package: + name: subpkg2 + dependencies: + - prelude: ">=6.0.0 <6.0.1" diff --git a/test-fixtures/upgrade/solver-workspace-only/subpkg2/src/Main.purs b/test-fixtures/upgrade/solver-workspace-only/subpkg2/src/Main.purs new file mode 100644 index 000000000..71ba25336 --- /dev/null +++ b/test-fixtures/upgrade/solver-workspace-only/subpkg2/src/Main.purs @@ -0,0 +1,6 @@ +module Subpkg2.Main where + +import Prelude + +greeting :: String +greeting = "Hello from subpkg2" diff --git a/test/Spago/Config.purs b/test/Spago/Config.purs index bed40f10c..4f7e3caea 100644 --- a/test/Spago/Config.purs +++ b/test/Spago/Config.purs @@ -8,7 +8,6 @@ import Data.String as String import Registry.License as License import Registry.Location (Location(..)) import Registry.PackageName as PackageName -import Registry.Range as Range import Registry.Version as Version import Spago.Core.Config (SetAddress(..)) import Spago.Core.Config as C @@ -37,7 +36,7 @@ spec = <> "\n\n\n-------\nActual:\n-------\n" <> Yaml.stringifyYaml C.configCodec parsed - Spec.it "parses exact version ranges (e.g. '1.0.0' -> '>=1.0.0 <1.0.1')" do + Spec.it "parses exact version (e.g. '6.0.1') as ExactVersion" do let yaml = """ package: @@ -47,8 +46,9 @@ spec = """ parsed = unsafeFromRight $ Yaml.parseYaml C.configCodec yaml C.Dependencies deps = (unsafeFromJust parsed.package).dependencies - actual = Map.lookup (mkPackageName "prelude") deps <#> map Range.print - actual `Assert.shouldEqual` Just (Just ">=6.0.1 <6.0.2") + actual = Map.lookup (mkPackageName "prelude") deps + expected = Just (Just (C.ExactVersion (unsafeFromRight $ Version.parse "6.0.1"))) + actual `Assert.shouldEqual` expected Spec.it "reports errors" do Yaml.parseYaml C.configCodec invalidLicenseYaml `shouldFailWith` diff --git a/test/Spago/Install.purs b/test/Spago/Install.purs index a9a4e3e39..e09d888c4 100644 --- a/test/Spago/Install.purs +++ b/test/Spago/Install.purs @@ -80,13 +80,13 @@ spec = Spec.around withTempDir do } ) ( Dependencies $ Map.fromFoldable - [ Tuple (mkPackageName "prelude") (Just $ mkRange ">=6.0.0 <7.0.0") - , Tuple (mkPackageName "lists") (Just $ mkRange ">=1000.0.0 <1000.0.1") + [ Tuple (mkPackageName "prelude") (Just $ Config.VersionRange $ mkRange ">=6.0.0 <7.0.0") + , Tuple (mkPackageName "lists") (Just $ Config.VersionRange $ mkRange ">=1000.0.0 <1000.0.1") ] ) ( Dependencies $ Map.fromFoldable - [ Tuple (mkPackageName "spec") (Just $ mkRange ">=7.0.0 <8.0.0") - , Tuple (mkPackageName "maybe") (Just $ mkRange ">=1000.0.0 <1000.0.1") + [ Tuple (mkPackageName "spec") (Just $ Config.VersionRange $ mkRange ">=7.0.0 <8.0.0") + , Tuple (mkPackageName "maybe") (Just $ Config.VersionRange $ mkRange ">=1000.0.0 <1000.0.1") ] ) diff --git a/test/Spago/Lock.purs b/test/Spago/Lock.purs index f8e8e0c31..4b655627b 100644 --- a/test/Spago/Lock.purs +++ b/test/Spago/Lock.purs @@ -7,7 +7,7 @@ import Data.Map as Map import Registry.Range as Range import Registry.Sha256 as Sha256 import Registry.Version as Version -import Spago.Core.Config (Dependencies(..), ExtraPackage(..), RemotePackage(..), SetAddress(..)) +import Spago.Core.Config (Dependencies(..), ExtraPackage(..), RemotePackage(..), SetAddress(..), VersionConstraint(..)) import Spago.Core.Config as Config import Spago.Core.Config as Core import Spago.FS as FS @@ -40,7 +40,7 @@ validLockfile = [ packageTuple "my-app" { core: { dependencies: Dependencies $ Map.fromFoldable - [ packageTuple "effect" (Just (unsafeFromRight (Range.parse ">=1.0.0 <5.0.0"))) + [ packageTuple "effect" (Just (VersionRange (unsafeFromRight (Range.parse ">=1.0.0 <5.0.0")))) , packageTuple "my-library" Nothing ] } @@ -54,7 +54,7 @@ validLockfile = { dependencies: Dependencies $ Map.fromFoldable [ packageTuple "prelude" Nothing ] } , test: - { dependencies: Dependencies $ Map.fromFoldable [ packageTuple "console" (Just Config.widestRange) ] + { dependencies: Dependencies $ Map.fromFoldable [ packageTuple "console" (Just (VersionRange Config.widestRange)) ] } , path: "my-library" } diff --git a/test/Spago/Upgrade.purs b/test/Spago/Upgrade.purs index 3a797e68b..8b9716031 100644 --- a/test/Spago/Upgrade.purs +++ b/test/Spago/Upgrade.purs @@ -2,13 +2,16 @@ module Test.Spago.Upgrade where import Test.Prelude +import Data.String as String import Effect.Now as Now import Spago.Config (SetAddress(..)) import Spago.Config as Config +import Spago.FS as FS import Spago.Log (LogVerbosity(..)) import Test.Spec (Spec) import Test.Spec as Spec import Test.Spec.Assertions as Assert +import Test.Spec.Assertions.String (shouldContain, shouldNotContain) spec :: Spec Unit spec = Spec.around withTempDir do @@ -37,13 +40,163 @@ spec = Spec.around withTempDir do , error: "Could not upgrade the package set to 20.0.1." } - where - assertExpectedVersion root { check, error } = do - startingTime <- liftEffect $ Now.now - maybeConfig <- runSpago - { logOptions: { color: false, verbosity: LogQuiet, startingTime }, rootPath: root } - (Config.readConfig $ root "spago.yaml") - case maybeConfig of - Right { yaml: { workspace: Just { packageSet: Just (SetFromRegistry { registry }) } } } | check registry -> pure unit - Right { yaml: c } -> Assert.fail $ error <> " Config: " <> printJson Config.configCodec c - Left err -> Assert.fail $ "Could not read config: " <> show err + Spec.describe "solver projects" do + Spec.it "upgrades solver project dependencies" \{ spago, testCwd } -> do + spago [ "init", "--name", "test-upgrade", "--use-solver" ] >>= shouldBeSuccess + spago [ "install", "either" ] >>= shouldBeSuccess + + -- Narrow either's range so upgrade will actually change it + replaceConstraint (testCwd "spago.yaml") "either" ">=6.0.0 <6.0.1" + + spago [ "upgrade" ] >>= shouldBeSuccess + + -- Verify range was actually widened (no longer narrow upper bound) + postConfig <- FS.readTextFile (testCwd "spago.yaml") + postConfig `shouldContain` "either:" + postConfig `shouldNotContain` "<6.0.1" + + -- Verify project still builds after upgrade + spago [ "build" ] >>= shouldBeSuccess + + Spec.it "handles empty dependencies gracefully" \{ spago, testCwd, fixture } -> do + FS.copyTree + { src: fixture "upgrade/solver-empty-deps" + , dst: testCwd + } + + result <- spago [ "upgrade" ] + shouldBeSuccess result + either _.stderr _.stderr result `shouldContain` "Upgrade successful!" + + Spec.it "upgrades all packages when none selected in multi-package workspace" \{ spago, testCwd, fixture } -> do + FS.copyTree + { src: fixture "upgrade/solver-workspace-only" + , dst: testCwd + } + + -- Save original configs for comparison + subpkgOriginal <- FS.readTextFile (testCwd "subpkg" "spago.yaml") + subpkg2Original <- FS.readTextFile (testCwd "subpkg2" "spago.yaml") + + -- With -p flag should upgrade only selected package + spago [ "upgrade", "-p", "subpkg" ] >>= shouldBeSuccess + + -- Verify subpkg was upgraded (different from original) + subpkgAfterP <- FS.readTextFile (testCwd "subpkg" "spago.yaml") + subpkgAfterP `Assert.shouldNotEqual` subpkgOriginal + + -- Verify subpkg2 was NOT upgraded (still matches original) + subpkg2AfterP <- FS.readTextFile (testCwd "subpkg2" "spago.yaml") + subpkg2AfterP `Assert.shouldEqual` subpkg2Original + + -- Without -p flag, should upgrade all packages + spago [ "upgrade" ] >>= shouldBeSuccess + + -- Now verify subpkg2 was upgraded (different from original) + subpkg2AfterAll <- FS.readTextFile (testCwd "subpkg2" "spago.yaml") + subpkg2AfterAll `Assert.shouldNotEqual` subpkg2Original + + Spec.it "upgrades test dependencies" \{ spago, testCwd } -> do + spago [ "init", "--name", "test-deps", "--use-solver" ] >>= shouldBeSuccess + spago [ "install", "--test-deps", "assert" ] >>= shouldBeSuccess + + spago [ "upgrade" ] >>= shouldBeSuccess + + -- Verify test deps still exist and bare deps stay bare (no colon = no range) + postConfig <- FS.readTextFile (testCwd "spago.yaml") + postConfig `shouldContain` "- assert" + postConfig `shouldNotContain` "assert:" + postConfig `shouldContain` "test:" + + -- Verify project still builds after upgrade + spago [ "build" ] >>= shouldBeSuccess + + Spec.it "unions existing ranges with new caret range" \{ spago, testCwd } -> do + spago [ "init", "--name", "test-union", "--use-solver" ] >>= shouldBeSuccess + + -- Set a narrow range: wide lower bound (>=5.0.0) and narrow upper bound (<6.0.1) + -- This tests both aspects of union: + -- - Lower bound should be preserved (not bumped to latest) + -- - Upper bound should be extended (via union with caret of latest) + replaceConstraint (testCwd "spago.yaml") "prelude" ">=5.0.0 <6.0.1" + + spago [ "upgrade" ] >>= shouldBeSuccess + + postConfig <- FS.readTextFile (testCwd "spago.yaml") + -- Lower bound preserved (not bumped to >=6.x.x) + postConfig `shouldContain` ">=5.0.0" + -- Upper bound extended (no longer the narrow <6.0.1) + postConfig `shouldNotContain` "<6.0.1" + + Spec.it "keeps exact versions as exact versions, and * as they are" \{ spago, testCwd } -> do + spago [ "init", "--name", "test-exact", "--use-solver" ] >>= shouldBeSuccess + + -- Set an exact version constraint + replaceConstraint (testCwd "spago.yaml") "prelude" "6.0.0" + + spago [ "upgrade" ] >>= shouldBeSuccess + + postConfig <- FS.readTextFile (testCwd "spago.yaml") + -- Should still be an exact version (no >= or <), just a different version + postConfig `shouldNotContain` ">=" + postConfig `shouldNotContain` "<" + -- Should have prelude with a colon (not bare), indicating it has a constraint + postConfig `shouldContain` "prelude:" + -- Original version should be replaced + postConfig `shouldNotContain` "6.0.0" + + -- Set widest range (*) + replaceConstraint (testCwd "spago.yaml") "prelude" "*" + + spago [ "upgrade" ] >>= shouldBeSuccess + + postConfig' <- FS.readTextFile (testCwd "spago.yaml") + -- Should still be "*" + postConfig' `shouldContain` "prelude: \"*\"" + + Spec.it "does not persist upgrade if build fails" \{ spago, testCwd } -> do + spago [ "init", "--name", "test-rollback", "--use-solver" ] >>= shouldBeSuccess + spago [ "install", "either" ] >>= shouldBeSuccess + + -- Narrow either's constraint so upgrade would change it + replaceConstraint (testCwd "spago.yaml") "either" ">=6.0.0 <6.0.1" + + -- Save original config + originalConfig <- FS.readTextFile (testCwd "spago.yaml") + + -- Add invalid PureScript code that will fail to compile + FS.writeTextFile (testCwd "src" "Main.purs") "this is not valid purescript" + + -- Attempt upgrade - should fail during build verification + result <- spago [ "upgrade" ] + shouldBeFailure result + either _.stderr _.stderr result `shouldContain` "Build failed" + either _.stderr _.stderr result `shouldContain` "Config was not modified" + + -- Config should be unchanged + postConfig <- FS.readTextFile (testCwd "spago.yaml") + postConfig `Assert.shouldEqual` originalConfig + + where + -- Helper to replace a dependency constraint in spago.yaml + replaceConstraint :: LocalPath -> String -> String -> Aff Unit + replaceConstraint configPath pkgName newConstraint = do + config <- FS.readTextFile configPath + let + lines = String.split (String.Pattern "\n") config + updatedLines = map updateLine lines + pattern = "- " <> pkgName + updateLine line + | String.contains (String.Pattern pattern) line = " - " <> pkgName <> ": \"" <> newConstraint <> "\"" + | otherwise = line + FS.writeTextFile configPath (String.joinWith "\n" updatedLines) + + assertExpectedVersion root { check, error } = do + startingTime <- liftEffect $ Now.now + maybeConfig <- runSpago + { logOptions: { color: false, verbosity: LogQuiet, startingTime }, rootPath: root } + (Config.readConfig $ root "spago.yaml") + case maybeConfig of + Right { yaml: { workspace: Just { packageSet: Just (SetFromRegistry { registry }) } } } | check registry -> pure unit + Right { yaml: c } -> Assert.fail $ error <> " Config: " <> printJson Config.configCodec c + Left err -> Assert.fail $ "Could not read config: " <> show err