Why does PVP need to care about reexports?

@Bodigrim commented here saying:

Imagine re-exporting HUnitFailure from HUnit in tasty-hunit-0.11.0.0 with build-depends: HUnit >= 1.6 && < 1.7 . If HUnit-1.6.3.0 adds any instance for HUnitFailure , it will leak from tasty-hunit-0.11.0.0 as well. This means that tasty-hunit-0.11.0.0 fails to provide a stable interface and clients with build-depends: tasty-hunit == 0.11.0.0 have no control of it.

IMO this is just an example for why you should pin transitive dependencies (e.g. with a snapshot or with a freeze file). Any other language ecosystem (I’m familiar with npm + pypi) is perfectly fine with packages reexporting from other packages, and it’s commonly understood that you should pin transitive deps to get truly reproducible builds.

I get that Haskell instances are a little more magical (global + automatically reexported), but I don’t see why it’s any different than reexporting a constructor and the upstream package changing fields. Maybe it’s because docs will get outdated? I’m curious if Rust has similar issues then.

2 Likes

I don’t think “npm” is perfectly fine with packages re-exporting from other packages, especially since you get a tree of dependencies and not a graph. They have an additional mechanism for when this is needed known as “peer dependencies”, which to go back to the tasty-hunit example, would require the user to explicitly specify HUnit (and tasty) as a dependency.

If the upstream package changes fields on a constructor, that’s a breaking change and major version bump according to the PVP. Adding a non-orphan instances is not a breaking change and only requires a minor version bump. So if I depend on PkgA, which depends on PkgB, and PkgA re-exports a type, TypC from PkgB, and I add an orphan instance to TypC, the minor version of PkgB affects me, but doesn’t affect PkgA. Luckily however, the PVP already specifies the solution (emphasis mine)

Client defines orphan instance. If a package defines an orphan instance, it MUST depend on the minor version of the packages that define the data type and the type class to be backwards compatible. For example, build-depends: mypkg >= 2.1.1 && < 2.1.2.

So, going back to the tasty-hunit example - adding an orphan instance in your code for HUnitFailure would require you to add a dependency HUnit >= 1.6.3 && < 1.6.4. If you never specified an orphan instance, and HUnit has added a new instance to HUnitFailure the only thing that has changed was that if your code didn’t compile before, it may compile now.

1 Like

Here is my point of view.

If you are developing a project, you are fully in control and you should definitely strive for reproducibility. The tools you mention (snapshot or a freeze file) are prefectly adequate.

If you are developing a package, you are not in control. It’s your users, not you, building your package. And your package will have to work with other packages too. It would be very unlikely that two packages pinning all transitive dependencies would be able to work together.

Of course, if we alll decide to use the same snapshot pinning transitive dependencies would not be a problem; but also nobody will be able to use your package until it becomes part of the snapshot.

Version bounds on dependencies (and not version pins) is how we give the user flexibility in choosing their dependencies.

1 Like

I’m not saying library authors should pin their deps. I’m saying that library authors shouldnt need to bump versions just because one of their reexports has a change.

Take this extreme example: say you have a package pkgB that just reexports things from pkgA, with no new code. For now, let’s pretend pkgA never deletes code, so pkgB will always compile, it just might pass along breaking changes if pkgA changes something (e.g. new constructor, new field, etc). I’d be perfectly fine with pkgB not bumping versions whenever pkgA changes something. If users pin transitive deps, there would never be a problem with this approach. The only problem would be if a user pinned pkgB and upgraded pkgA underneath pkgB. I’m saying the library author shouldnt need to care about this; we should expect users to understand that pinning transitive deps is the only way to guarantee reproducible builds.

@Probie: sure, ok forget what I said about instances, let’s just focus on reexporting types. To clarify, when I said other language ecosystems are fine with reexports, I meant that in languages like python or typescript, as a user, I don’t have any expectations of authors reflecting reexported APIs in the version. I’m simply aware that pinning transitive deps is required and I shouldnt expect my usage of direct deps to work if I bump transitive deps underneath pinned direct deps.

Looking it up now, I do see that Semver talks about “breaking changes in the public API”, and I see some discussion around the Rust community about reexports. But even so, as a user using python/javascript libraries ostensibly on semver, I don’t usually have a problem with libraries reexporting things without reflecting changes in the version. I just pin transitive deps, and recognize that if i bump versions, i need to fix things (whether the version bump upgrades direct or transitive deps)