An Old Bug

Recently, I happened across weird line in read-package-tree while reading through the code to see where I might get started implementing Workspaces for the npm CLI.

At the time, I was so deep in the flow of reading code and tracing flows through various parts of the system, it didn't strike me how important it was. I just thought "oh, that's obviously wrong" and fixed it without a second thought. When I tried to integrate my changes back to the mainline CLI with read-package-tree version 5.3.0, however, I realized what I'd found.

It might not seem weird at first, so I'll provide some context.

The read-package-tree module reads the tree of packages in a node_modules folder. It doesn't do dependency resolution, or figure out how that tree needs to be modified during an install or update, but it does provide the basic data structure that is subsequently modified within the npm CLI installer codebase. Most importantly, it loads Node objects with parent and children links on them.

Node.js's module loader has a subtle and important property to it, which makes a lot of npm's magic possible, and has made it such a good choice over the years as a way to create CLI utilities. When a module in Node calls require('foo'), Node looks up through the node_modules hierarchy to find the foo module. However, importantly, when a package is symlinked into another location, the module resolution process runs based on the target location, not the link location.

This is why, for example, you can have a symlink from /usr/local/bin/npm that points to /usr/local/lib/node_modules/npm/bin/npm-cli.js, but the program doesn't have to be updated to carry all its dependencies along with it. This is how you can have multiple different versions of a dependency loaded in the same Node program at the same time, providing a release valve on the dependency constraint solver and thus avoiding dependency hell.

So back to this weird line. In the context of that file, this bit of code is loading up the children of a given node. In order to match Node's module resolution algorithm, that should be reading the target and then loading the children there, rather than treating the link as the parent.

In the mindset of general code cleanup, I modified it without even really noticing and then mistakenly let it slip past into a completely unrelated commit.

When I tried to integrate read-package-tree (with this change) into the npm CLI, a bunch of tests broke in really bizarre ways. That's when it hit me what was going on, and a lot of strange CLI behavior over the years started to make a ton of sense. Like, how occasionally a manually symlinked module would result in its dependencies being stripped away or mutated in some way that seemed to make no sense. (I'm sure most people don't do this, but as someone who is probably too comfortable with module systems, I often mess with my projects with reckless abandon.)

Prior to npm v3, there was no deduplication by default, and installation happened in a very naive "run in parallel, fetch whatever is still needed at that level" kind of way. It was dumb, in a mostly good way, but the result was that occasionally you'd get 15 copies of something when 1 would do just fine. It also meant that a proper progress bar was impossible, since the installer had no way of knowing when it would be done. (Simple loop detection helped it know that eventually it would be done, but not when, exactly.)

Over the years, npm has had a lot of bugfixes and logic added to work around the issues that this one subtle mistake caused. The result of all that fixing, however, means that fixing the bug breaks a lot of stuff that works just fine today. The good news is that npm v7 will have Workspaces, and (once the installer is completely refactored into @npmcli/arborist), it'll be trivial to implement.

Out of idle curiosity, I tracked down where this bug had snuck in, though I was pretty sure I already knew the answer. It dates back to the original inception of the read-package-tree module. It really should have been caught and fixed in db70482 but, well, it wasn't.

Spotted my past self red-handed, messing with me as usual, sneaking bugs into my code. If I ever catch that guy, he'll have quite a few things to answer for.