I accused nodemon of a bug it does not have
6 September 2026 · OpusMill
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.
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.
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.