diff --git a/packages/constraint-solver/constraint-solver-input.js b/packages/constraint-solver/constraint-solver-input.js index bce75a6c1c..f19d05b86b 100644 --- a/packages/constraint-solver/constraint-solver-input.js +++ b/packages/constraint-solver/constraint-solver-input.js @@ -13,6 +13,7 @@ CS.Input = function (dependencies, constraints, catalogCache, options) { self.upgrade = options.upgrade || []; self.anticipatedPrereleases = options.anticipatedPrereleases || {}; self.previousSolution = options.previousSolution || null; + self.mayBreakRootDependencies = options.mayBreakRootDependencies || false; check(self.dependencies, [String]); check(self.constraints, [PackageConstraintType]); @@ -89,7 +90,10 @@ CS.Input.prototype.toJSONable = function () { } if (self.previousSolution !== null) { obj.previousSolution = self.previousSolution; - }; + } + if (self.mayBreakRootDependencies) { + obj.mayBreakRootDependencies = true; + } return obj; }; @@ -101,7 +105,8 @@ CS.Input.fromJSONable = function (obj) { anticipatedPrereleases: Match.Optional( Match.ObjectWithValues(Match.ObjectWithValues(Boolean))), previousSolution: Match.Optional(Match.OneOf(Object, null)), - upgrade: Match.Optional([String]) + upgrade: Match.Optional([String]), + mayBreakRootDependencies: Match.Optional(Boolean) }); return new CS.Input( @@ -113,7 +118,8 @@ CS.Input.fromJSONable = function (obj) { { upgrade: obj.upgrade, anticipatedPrereleases: obj.anticipatedPrereleases, - previousSolution: obj.previousSolution + previousSolution: obj.previousSolution, + mayBreakRootDependencies: obj.mayBreakRootDependencies }); }; diff --git a/packages/constraint-solver/constraint-solver.js b/packages/constraint-solver/constraint-solver.js index 4865352387..77d9e7d301 100644 --- a/packages/constraint-solver/constraint-solver.js +++ b/packages/constraint-solver/constraint-solver.js @@ -27,6 +27,8 @@ CS.PackagesResolver = function (catalog, options) { // included versions are the only pre-releases that are allowed to match // constraints that don't specifically name them during the "try not to // use unanticipated pre-releases" pass +// - mayBreakRootDependencies: allows choosing versions of +// root dependencies that are incompatible with the previous solution // - missingPreviousVersionIsError - throw an error if a package version in // previousSolution is not found in the catalog CS.PackagesResolver.prototype.resolve = function (dependencies, constraints, @@ -35,7 +37,8 @@ CS.PackagesResolver.prototype.resolve = function (dependencies, constraints, options = options || {}; var input = new CS.Input(dependencies, constraints, self.catalogCache, _.pick(options, 'upgrade', 'anticipatedPrereleases', - 'previousSolution')); + 'previousSolution', + 'mayBreakRootDependencies')); input.loadFromCatalog(self.catalogLoader); if (options.previousSolution && options.missingPreviousVersionIsError) { diff --git a/packages/constraint-solver/input-tests.js b/packages/constraint-solver/input-tests.js index b65faa7f47..dbbc9846cf 100644 --- a/packages/constraint-solver/input-tests.js +++ b/packages/constraint-solver/input-tests.js @@ -35,6 +35,21 @@ var doTest = function (test, inputJSONable, outputJSONable) { formatSolution(outputJSONable)); }; +var doFailTest = function (test, inputJSONable, messageExpect) { + var input = CS.Input.fromJSONable(inputJSONable); + + test.throws(function () { + try { + CS.PackagesResolver._resolveWithInput(input); + } catch (e) { + if (! e.constraintSolverError) { + test.fail(e.message); + } + throw e; + } + }, messageExpect); +}; + Tinytest.add("constraint solver - input - upgrade indirect dependency", function (test) { doTest(test, { dependencies: ["foo"], @@ -79,6 +94,42 @@ Tinytest.add("constraint solver - input - previous solution no patch", function }); }); + +Tinytest.add("constraint solver - input - don't break root dep", function (test) { + doTest(test, { + dependencies: ["foo", "bar"], + constraints: [], + previousSolution: { bar: "2.0.0" }, + upgrade: [], + catalogCache: { + data: { + "foo 1.0.0": ["bar@=2.0.1"], + "bar 2.0.0": [], + "bar 2.0.1": [] + } + } + }, { + answer: { + foo: "1.0.0", + bar: "2.0.1" + } + }); + + doFailTest(test, { + dependencies: ["foo", "bar"], + constraints: [], + previousSolution: { bar: "2.0.1" }, + upgrade: [], + catalogCache: { + data: { + "foo 1.0.0": ["bar@=2.0.0"], + "bar 2.0.0": [], + "bar 2.0.1": [] + } + } + }, 'Breaking change required to top-level dependency: bar 2.0.0, was 2.0.1.\nConstraints:\n bar@=2.0.0 <- foo 1.0.0\nTo make breaking changes to top-level dependencies, you must pass --breaking.'); +}); + Tinytest.add("constraint solver - input - slow solve", function (test) { var input = CS.Input.fromJSONable({ "dependencies": [ diff --git a/packages/constraint-solver/solver.js b/packages/constraint-solver/solver.js index 7d3aea0860..471d1e82d6 100644 --- a/packages/constraint-solver/solver.js +++ b/packages/constraint-solver/solver.js @@ -175,6 +175,23 @@ CS.Solver.prototype.minimize = function (step, costTerms_, costWeights_) { } }; +// Determine the non-zero contributions to the cost function in `step` +// based on the current solution, returning a map from term (usually +// the name of a package or package version) to positive integer cost. +CS.Solver.prototype.getStepContributions = function (step) { + var self = this; + var solution = self.solution; + var contributions = {}; + var weights = step.weights; + _.each(step.terms, function (t, i) { + var w = (typeof weights === 'number' ? weights : weights[i]); + if (w && self.solution.evaluate(t)) { + contributions[t] = w; + } + }); + return contributions; +}; + CS.Solver.prototype.analyzeReachability = function () { var self = this; var input = self.input; @@ -445,21 +462,48 @@ CS.Solver.prototype.getSolution = function () { // cost function, so we can show a better error. self.minimize('conflicts', _.pluck(analysis.constraints, 'conflictVar')); - // XXX This is where we will enforce that we don't make breaking changes - // to your root dependencies, unless you pass --breaking. + var previousRootSteps = self.getDistances( + 'previous_root', analysis.previousRootDepVersions); + // the "previous_root_incompat" step + var previousRootIncompat = previousRootSteps[0]; + // the "previous_root_major", "previous_root_minor", etc. steps + var previousRootVersionParts = previousRootSteps.slice(1); var toUpdate = _.filter(input.upgrade, function (p) { return analysis.reachablePackages[p] === true; }); + if (! input.mayBreakRootDependencies) { + // make sure packages that are being updated can still count as + // a previous_root for the purposes of previous_root_incompat + _.each(toUpdate, function (p) { + if (input.isRootDependency(p) && input.isInPreviousSolution(p)) { + var cats = self.pricer.categorizeVersions( + cache.getPackageVersions(p), input.previousSolution[p]); + _.each(cats.before.concat(cats.higherMajor), function (v) { + previousRootIncompat.addTerm(pvVar(p, v), 1); + }); + } + }); + } + + if (! input.mayBreakRootDependencies) { + // Enforce that we don't make breaking changes to your root dependencies, + // unless you pass --breaking. It will actually be enforced farther down, + // but for now, we want to apply this constraint before handling updates. + self.minimize(previousRootIncompat); + } + self.minimize(self.getOldnesses('update', toUpdate)); var newRootDeps = _.filter(input.dependencies, function (p) { return ! input.isInPreviousSolution(p); }); - self.minimize(self.getDistances( - 'previous_root', analysis.previousRootDepVersions)); + if (input.mayBreakRootDependencies) { + self.minimize(previousRootIncompat); + } + self.minimize(previousRootVersionParts); var otherPrevious = _.filter(_.map(input.previousSolution, function (v, p) { return new CS.PackageAndVersion(p, v); @@ -493,10 +537,6 @@ CS.Solver.prototype.getSolution = function () { } }); - //_.each(otherPackages, function (package) { - //self.minimize(self.getGravityPotential( - //'new_indirect(' + package + ')', [package])); - //}); self.minimize(self.getGravityPotential('new_indirect', otherPackages)); self.minimize('total_packages', _.keys(analysis.reachablePackages)); @@ -525,6 +565,23 @@ CS.Solver.prototype.getSolution = function () { self.throwConflicts(); } + if ((! input.mayBreakRootDependencies) && + self.stepsByName.previous_root_incompat.optimum > 0) { + _.each(_.keys( + self.getStepContributions(self.stepsByName.previous_root_incompat)), + function (pvStr) { + var pv = CS.PackageAndVersion.fromString(pvStr); + var prevVersion = input.previousSolution[pv.package]; + self.errors.push( + 'Breaking change required to top-level dependency: ' + + pvStr + ', was ' + prevVersion + '.\n' + + self.listConstraintsOnPackage(pv.package)); + }); + self.errors.push('To allow breaking changes to top-level dependencies, you ' + + 'must pass --breaking to meteor [run], update, add, or remove.'); + self.throwAnyErrors(); + } + var versionMap = self.currentVersionMap(); return { @@ -589,6 +646,30 @@ var _getConstraintFormula = function (toPackage, vConstraint) { } }; +CS.Solver.prototype.listConstraintsOnPackage = function (package) { + var self = this; + var constraints = self.analysis.constraints; + + var result = 'Constraints:'; + + _.each(constraints, function (c) { + if (c.toPackage === package) { + var paths; + if (c.fromVar) { + paths = self.getPathsToPackageVersion( + CS.PackageAndVersion.fromString(c.fromVar)); + } else { + paths = [['top level']]; + } + _.each(paths, function (path) { + result += '\n* ' + (new PV.PackageConstraint( + package, c.vConstraint.raw)) + ' <- ' + path.join(' <- '); + }); + } + }); + + return result; +}; CS.Solver.prototype.throwConflicts = function () { var self = this; @@ -615,23 +696,7 @@ CS.Solver.prototype.throwConflicts = function () { c.toPackage, c.vConstraint)) + ' is not satisfied by ' + c.toPackage + ' ' + chosenVersion + '.'); - error += '\nConstraints:'; - - _.each(constraints, function (c2) { - if (c2.toPackage === c.toPackage) { - var paths; - if (c2.fromVar) { - paths = self.getPathsToPackageVersion( - CS.PackageAndVersion.fromString(c2.fromVar)); - } else { - paths = [['top level']]; - } - _.each(paths, function (path) { - error += '\n ' + (new PV.PackageConstraint( - c.toPackage, c2.vConstraint)) + ' <- ' + path.join(' <- '); - }); - } - }); + error += '\n' + self.listConstraintsOnPackage(c.toPackage); self.errors.push(error); } diff --git a/tools/commands-packages.js b/tools/commands-packages.js index 9b954ca95f..1a95198a6d 100644 --- a/tools/commands-packages.js +++ b/tools/commands-packages.js @@ -1470,7 +1470,8 @@ main.registerCommand({ name: 'update', options: { patch: { type: Boolean, required: false }, - "packages-only": { type: Boolean, required: false } + "packages-only": { type: Boolean, required: false }, + breaking: { type: Boolean, required: false } }, // We have to be able to work without a release, since 'meteor // update' is how you fix apps that don't have a release. @@ -1514,7 +1515,8 @@ main.registerCommand({ // we're done even if we're not on the matching release!) var projectContext = new projectContextModule.ProjectContext({ projectDir: options.appDir, - alwaysWritePackageMap: true + alwaysWritePackageMap: true, + mayBreakRootDependencies: options.breaking }); main.captureAndExit("=> Errors while initializing project:", function () { projectContext.readProjectMetadata(); @@ -1617,13 +1619,17 @@ main.registerCommand({ main.registerCommand({ name: 'add', + options: { + breaking: { type: Boolean, required: false } + }, minArgs: 1, maxArgs: Infinity, requiresApp: true, catalogRefresh: new catalog.Refresh.OnceAtStart({ ignoreErrors: true }) }, function (options) { var projectContext = new projectContextModule.ProjectContext({ - projectDir: options.appDir + projectDir: options.appDir, + mayBreakRootDependencies: options.breaking }); main.captureAndExit("=> Errors while initializing project:", function () { // We're just reading metadata here --- we're not going to resolve @@ -1797,13 +1803,17 @@ main.registerCommand({ /////////////////////////////////////////////////////////////////////////////// main.registerCommand({ name: 'remove', + options: { + breaking: { type: Boolean, required: false } + }, minArgs: 1, maxArgs: Infinity, requiresApp: true, catalogRefresh: new catalog.Refresh.Never() }, function (options) { var projectContext = new projectContextModule.ProjectContext({ - projectDir: options.appDir + projectDir: options.appDir, + mayBreakRootDependencies: options.breaking }); main.captureAndExit("=> Errors while initializing project:", function () { // We're just reading metadata here --- we're not going to resolve diff --git a/tools/commands.js b/tools/commands.js index 8f427f97a4..b38e5695db 100644 --- a/tools/commands.js +++ b/tools/commands.js @@ -173,7 +173,10 @@ var runCommandOptions = { // With --clean, meteor cleans the application directory and uses the // bundled assets only. Encapsulates the behavior of once (does not rerun) // and does not monitor for file changes. Not for end-user use. - clean: { type: Boolean} + clean: { type: Boolean}, + // Allow the version solver to make breaking changes to the versions + // of top-level dependencies. + breaking: { type: Boolean } }, catalogRefresh: new catalog.Refresh.Never() }; @@ -220,7 +223,8 @@ function doRunCommand (options) { options.httpProxyPort = options['http-proxy-port']; var projectContext = new projectContextModule.ProjectContext({ - projectDir: options.appDir + projectDir: options.appDir, + mayBreakRootDependencies: options.breaking }); main.captureAndExit("=> Errors while initializing project:", function () { diff --git a/tools/help.txt b/tools/help.txt index f60e5d5c34..9f12ce020c 100644 --- a/tools/help.txt +++ b/tools/help.txt @@ -57,6 +57,9 @@ Options: --settings Set optional data for Meteor.settings on the server. --release Specify the release of Meteor to use. --verbose Print all output from builds logs. + --breaking Allow packages in your project to be upgraded or downgraded + to versions that are not semver-compatible with the current + versions, if required to resolve package version conflicts. --test [Experimental] Run Velocity tests using phantomjs and exit. >>> debug @@ -143,6 +146,9 @@ Options: --packages-only Update the package versions only. Do not update the release. --patch Update the release to a patch release only. --release Update to a specific release of meteor. + --breaking Allow packages in your project to be upgraded or downgraded + to versions that are not semver-compatible with the current + versions, if required to resolve package version conflicts. >>> admin run-upgrader @@ -161,6 +167,11 @@ Adds packages to your Meteor project. You can add multiple packages with one command. To query for available packages, use the meteor search command. +Options: + --breaking Allow packages in your project to be upgraded or downgraded + to versions that are not semver-compatible with the current + versions, if required to resolve package version conflicts. + >>> remove Remove a package from this project. @@ -170,6 +181,11 @@ Removes a package previously added to your Meteor project. For a list of the packages that your application is currently using, see 'meteor list'. +Options: + --breaking Allow packages in your project to be upgraded or downgraded + to versions that are not semver-compatible with the current + versions, if required to resolve package version conflicts. + >>> list List the packages explicitly used by your project. diff --git a/tools/project-context.js b/tools/project-context.js index fd2ebc2749..2383bdb73e 100644 --- a/tools/project-context.js +++ b/tools/project-context.js @@ -139,6 +139,11 @@ _.extend(ProjectContext.prototype, { self._cachedVersionsBeforeReset = null; } + // The --breaking command-line switch, which allows the version solver + // to choose versions of root dependencies that are incompatible with + // the previous solution. + self._mayBreakRootDependencies = options.mayBreakRootDependencies; + // Initialized by readProjectMetadata. self.releaseFile = null; self.projectConstraintsFile = null; @@ -402,6 +407,7 @@ _.extend(ProjectContext.prototype, { var resolveOptions = { previousSolution: cachedVersions, anticipatedPrereleases: anticipatedPrereleases, + mayBreakRootDependencies: self._mayBreakRootDependencies, // Not finding an exact match for a previous version in the catalog // is considered an error if we haven't refreshed yet, and will // trigger a refresh and another attempt. That way, if a previous