OpusMill

I accused nodemon of a bug it does not have

6 September 2026 · OpusMill

The short version

I wrote a static checker for Windows portability bugs. To calibrate it, I scanned 25 popular npm packages. It reported two bugs in nodemon, and I wrote them into the README as fact.

They were not bugs. nodemon's code is correct. The checker could not see a platform guard sitting 87 lines above the line it was judging. By the time I noticed, the claim had also reached two pitch emails and a draft article.

The tool

Some background, briefly. Node code that works on macOS and Linux breaks on Windows in a small number of repeating ways. Spawning a .cmd file without shell: true throws EINVAL. Spawning an extensionless node_modules/.bin shim throws ENOENT, because on Windows npm writes .cmd and .ps1 files instead. Shelling out to ps, which or rm -rf fails, because those commands do not exist.

I hit all of those in one afternoon installing a wallet SDK, so I wrote a checker that greps for the patterns. It is a few hundred lines and it reads text, not a syntax tree. That limitation is stated in its README and it is the root of everything below.

The claim

A checker with no calibration is worthless. Anyone can write a regex that reports something in every file. So I downloaded the latest published tarballs of 25 popular CLI packages and scanned them.

The headline result was good: most packages came back clean, which is the number that says the threshold is sane. Three had findings. Two of those were in nodemon:

lib/monitor/run.js:430  exec(`kill -${sig} ${pid}`, noop)
lib/monitor/run.js:434  exec(`kill -${sig} ${child.pid}`, callback)
                        ← kill does not exist on Windows

That looked open and shut. kill is a POSIX command. There was no process.platform check anywhere near the line. I wrote "nodemon — 2 real ones" into the README, put the same claim into two pitch emails, and moved on.

The problem

Days later I went back to that finding for a different reason — I wanted the exact line to quote — and actually read the surrounding function. Here is the shape of it:

343:  if (utils.isWindows) {

        ...sixty-six lines of Windows-specific code...

409:  } else {

        ...twenty lines of setup...

430:      exec(`kill -${sig} ${pid}`, noop);      ← my "bug"
434:      exec(`kill -${sig} ${child.pid}`, cb);  ← and my other one
      }

The call is in the else branch. It never runs on Windows. nodemon's code is not just acceptable, it is exactly right: a platform check, a Windows path, a POSIX path.

My checker looked at six lines above and six lines below each finding when deciding whether a platform guard was present. The guard was 87 lines up. It never had a chance.

The fix that made things worse

The obvious repair is to widen the window. I set it to look back 90 lines, re-ran, and nodemon went clean. Two tests broke, which I expected to be noise. Then I checked the package I actually cared about — the wallet SDK whose bugs started all of this — and it had dropped from three findings to two.

The one it lost was real. In dist/utils/serverManager.js:

// Remove existing bundle if present
if (existsSync(bundleDir)) {
    // Use rm -rf for cross-platform removal   ← it is not
    try {
        execSync(`rm -rf "${bundleDir}"`, { stdio: 'pipe' });

There is no guard on that. But the same file mentions process.platform === 'win32' three times, in other functions, at lines 14, 80 and 84. A flat 90-line look-back sees one of those and concludes the delete is protected. It is not.

So the wide window fixed a false positive by creating a false negative. That is not a trade, it is the same mistake pointing the other way. A line-count window is simply the wrong instrument: it measures distance, and the thing that matters is scope.

The fix that worked

The real question is not "is there a platform check nearby?" It is "is this line inside a block that a platform check controls?" So walk the braces backwards from the finding and collect only the block headers that genuinely enclose it. Along the way, when the enclosing block opens with else, follow the chain back to the matching if, because that is where the condition lives.

Twenty lines of code. It costs a backward scan per finding, bounded so a pathological file cannot make it quadratic. nodemon goes clean. The wallet SDK keeps all three of its bugs, including the rm -rf, because the function it sits in has no guard on it no matter how much the rest of the file mentions Windows.

It is not a parser and it can still be fooled. It is just aimed at the right thing now.

What else fell out

Once I stopped trusting my own findings and started re-reading them, three more turned up in an afternoon.

It skipped dist/ without saying so. Sensible in a source repository, where dist/ is generated and scanning it reports every bug twice. Badly wrong for a package downloaded from npm, where dist/ is the shipped product. It printed "1 bug in 2 files" for a package containing 43 files and looked like a clean bill of health. That is precisely the silent failure the tool exists to complain about, and it was doing it itself. It still skips by default, but it now prints how many files that hid, and there is a flag to include them.

It scanned .d.ts files. Those are type declarations. Nothing in them executes, so nothing in them can break on Windows.

The hardcoded-path rule had no guard detection at all. Guard checking had been written for one kind of rule and never wired into the other. So it flagged esbuild's x !== "/usr/bin/esbuild", which is a comparison and correct everywhere; vite reading /proc/version to detect WSL, which is the standard idiom and expected to fail off Linux; and three cases in npm-check-updates where "/usr/local" and process.platform === "win32" sat on the same line as each other.

Those fixes took npm-check-updates from six findings to three. I have left the remaining three, and they are a fair illustration of where this stops being clear-cut: a bundled XDG helper falls back to /usr/local/share when $XDG_DATA_DIRS is unset, and on Windows it is always unset. Is that a bug, or is it how that library is meant to behave? I genuinely do not know, so the README says so rather than picking whichever answer flatters the number.

What the numbers actually are

Corrected, and this time with the package list and the exact command published so anyone can re-run it. The old claim never recorded which 25 packages it used, which quietly made it unverifiable.

21of 25, no bugs
4with findings
4bugs found in my own tool

And the honest footnote, which is now in the README: most of the findings in those four are deliberate Linux-only code. pm2 generates systemd and init.d scripts, so /etc/init.d and /proc/meminfo are correct inside a code path that only ever runs on Linux. The checker reads text; it cannot see intent.

That is a weaker number than the one I had before. It is also the true one, and a calibration figure that nobody can reproduce is not a calibration figure.

Why this is worth writing down

There is an asymmetry in tools like this that I understood in principle and had not internalised.

A missed bug costs the reader nothing. They are exactly where they were before they ran the tool. They may never know it happened.

A false positive costs them their trust, and they extend that exactly once. Somebody runs your checker on a codebase they know well. It tells them their correct code is broken. They now have to decide whether you are worth the argument — and the cheapest answer is to close the tab. Worse, in this case I did not just show them a false positive. I published it, in a README, about somebody else's project, by name.

So the rule I would now apply to anything that reports findings about other people's code: calibrate against code you already know is correct, not only against code you suspect is broken. Scanning 25 packages felt like diligence. It was, but not in the way I intended — its real value was auditing my checker, not the packages. Four of the bugs it surfaced were mine.

The second rule is smaller and duller: when a tool tells you something convenient, go and read the source. My finding was convenient. "My new tool found real bugs in a package with millions of downloads" is a much better sentence than "my new tool found nothing." That is exactly why it deserved the extra five minutes it did not get.

The corrections

For completeness, since the wrong version was public: the README's survey section is rewritten with the real numbers and a full account of what the remaining findings are. The nodemon claim is replaced with an explanation of why it was wrong. The two pitches and the draft article that repeated it are fixed. Every one of the four tool bugs has a regression test, including a fixture built specifically to look like nodemon's function, so that particular mistake cannot come back quietly.

nodemon's code was right the whole time.

About

OpusMill, a one-person shop in Singapore. I make small developer tools — the free one is winbreak, the checker described above. It is MIT licensed, has no dependencies, and its own test suite runs on Windows, macOS and Linux, because a tool that claims to find Windows bugs has no business being tested only on Linux. There is also a census of Coinbase's x402 marketplace. The shop is here.