8000 Deprecation-aware semver solving · Issue #4521 · dart-lang/pub · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content
Deprecation-aware semver solving #4521
Open
@lrhn

Description

@lrhn

It's hard to publish a new major version of a widely used package. The wider the use, the more impossible it becomes.

Dart uses deprecation notices to warn people about up-coming changes, and we generally assume that if you get the warning, you will react on it and stop doing the deprecated thing (within some reasonable time).
That doesn't help with publishing a new major version at all, even if all that new version does is remove API that has been deprecated for years, and that (we presume) nobody uses any more, because all existing dependencies are still on ^X.Y.0 where X is the prior version.

One could choose to say that removing deprecated API is a non-breaking change after a certain time.
That's not how Semver works, if you said that you were compatible with ^1.0.0, then even if 1.82.0 is released four years later, only removing things that were deprecated for at least three years, it still mean that it's not compatible with 1.0.0. It's not Semver then, just plain versioning.

Proposal (strawman):

Allow a pubspec.yaml for a new major version to contain a "deprecation-compatible" version entry, fx. saying that 2.0.0 is deprecation compatible with 1.19.0.
It's intended to mean that if your code has no deprecation warnings when compiled against 1.19.0, then it should be compatible with 2.0.0 too.
It would allow version solving to solve a ^1.19.0 constraint with 2.0.0, because 2.0.0 has stated that it is in fact compatible with 1.19.0 - assuming you have no deprecation warnings, which we'll assume that you don't.
That basically treats deprecations as if they were errors, if you are aware of them, and we assume your ^1.19.0 code has no errors.

The compatibility entry only applies to prior major versions, you can't say that 2.5.0 is deprecation compatible with 2.2.0, because 2.5.0 can't remove anything from 2.2.0 anyway. (Or, again, it's not Semver. any more)
While 4.0.0 can technically claim to be deprecation compatible back to 2.17.0, that only makes sense if there were no deprecations added in 3.X releases and removed in 4.0.0, so usually it will just be the one prior version. It doesn't have to be on the latest release of the prior major version. If something was deprecated in 2.19.0, but that was released only two weeks ago, one might choose to not remove it in 3.0.0 anyway, so 3.0.0 only claims to be deprecation compatible with fx 2.17.0.

The deprecation-compatible version only applies to X.0.Z releases. We can allow point-releases, and unless there was a mistake in a prior release, they'll probably not change the deprecation compatibility. (They can technically increase it without removing any more API, that'll just make it harder to satisfy with a prior-version constraint.)
Pub can probably cache the deprecation-compatibility version for the most recent X.0.Z release and use that for all solving that has a requirement that is <X.0.0-0.
Then that should apply to all further X.Y.Z releases because those must all be API compatible with X.0.0.

This all assume that packages have fixed all deprecation warnings against their min-constraint of a package they depend on.
(And possibly that they won't directly interact with any objects from packages that they don't directly depend on.)

If we want to be safer, we can check whether another package has any deprecation warnings when resolved against its own min-constraint for the package in question, before we allow it to resolve to a deprecation-compatible next major version.

Is that what pub downgrade resolves to? I guess transitive constraints can make it solve above the min-constraint, and changes to transitive dependencies in later versions of other dependencies can make it possible to now solve to a lower version of the other package. Or force it to an higher version of our package, with more deprecations.
So if I have foo: ^2.18.0, it's possible that a pub downgrade can solve that to 2.18.0 or 2.19.0. It might be technically possible for a change to a dependency to make a later pub downgrade resolve to the other, changing in either direction. (Is solving even stable, could it resolve differently if I run pub downgrade again immediately?)

To avoid that, we could add a since parameter to Deprecated, so you write it as:

@Deprecated(since: "2.18", "Use banana instead")
static const curvedOrangeFruit = banana;
static const banana = 42;

Then the analyzer could give different warnings for deprecations depending on whether they're from a version at or before your dependency constraint, or after.
If you have foo: ^2.17.0, we cannot assume that you know that curvedOrangeFruit would be deprecated, and you get a FYI warning saying that the variable will be removed.
If you have foo: ^2.18.0 or later as constraint, you knew when you published this package that curvedOrangeFruit was already deprecated, and if you refer to it anyway, you chose to ignore that. That warrantes a stronger, or at least a different, warning, which can be recognized and treated differently if you want to.

Then Pub can run dart analyze on each package upload and see if it gets any of the second, stronger, deprecation warnings.
If so, it's not forwards-deprecation-compatible.

And the solving, if it can handle the complexity, can say that if package A is forwards-deprecation-compatible with package B version X.Y.0 (it has no strong deprecation warnings from deprecations with a since version <= X.Y from package B) and package B has released a next major version, (X+1).0.P that is backwards deprecation-compatible with X.Z.0 with Z <= Y, then package A is considered compatible with package B versions ^(X+1).0.P too.

And if the author of package A comes back to edit their package after the next major version of package B has been released,
Pub can tell them that they can safely change ^X.Y.0 to >=X.Y.0 <(X+2).0.0.

On top of that, we'll want more kinds of deprecation, markers that say that some API will change in the future, not just be removed.
Examples could be:

  • Will add abstract, interface, base, final or sealed to a class.
  • Will not implement a particular interface any more
  • Will change a generative constructor to a factory constructor.
  • Will make a parameter required.
  • Will remove covariant from a parameter.
  • Will change the type of a parameter, or return type of a function.
  • Will add a member or parameter with a given signature (a subclass can add its own implementation ahead of time,
    maybe even with an @override // ignore: ..not actually overriding.., or a parameter as optional, if possible.)

With such, one might be able to document all intended API changes ahead of time, so that people will know what to change to be considered compatible with the next major version.

That doesn't work if the next major version is actually a fundamentally different API. Then it's a clean break with no backwards compatibility.
But if all it does is remove deprecations or change the API in predictable ways, it's possible for depending packages to be prepared for that, so that they can be considered automatically compatible with the next major version, without having to say it themselves first, because the next major version has said that it's compatible with them.

This design puts all the work on the package author of the package with the deprecations, who wants to create a new major version release.
Even if you have done nothing so far, you can create one last version of the current major version where you deprecate everything you want to remove or change, and then, at the same time, release the next major version removing all those things and claiming to be backwards deprecation compatible with that one prior-version releease.

Then everybody else who depends on the package can start seeing that the deprecations exist, they can fix the deprecations and upgrade their minimum, even if only to the last version of the lower of the major versions. They'll keep running, because the
lower major version is still a solution.
When every package in an application has done so, when every package is next-major-version deprecation compatible, they will automatically start using the higher major version. And at that point, someone might start using an ^(X+1).0.0 dependency without breaking anything.

Metadata

Metadata

Assignees

No one assigned

    Labels

    type-enhancementA request for a change that isn't a bug

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

      0