Porting picomatch to Go: what broke, what we proved, and what we'd do differently
A Track F (JS → Go) submission for Hackathon Raptors
We picked picomatch — a small, heavily-used glob-matching library (a dependency of micromatch, fast-glob, and a huge chunk of the JS build-tool ecosystem) — and ported it to Go as pomatch. Not a rewrite that "looks similar." A port we could actually prove was equivalent.
Here's what that took.
The wall we hit on hour one: regex engines aren't interchangeable
picomatch's whole strategy is elegant: parse a glob pattern, emit a JavaScript regex source string, new RegExp() it, test inputs against it. Clean pipeline. Should port easily.
It didn't. picomatch's emitted regexes lean on negative lookahead (dotfile exclusion, the globstar atom) and the test suite exercises lookbehind and backreferences. Go's standard regexp package is RE2-based — RE2 doesn't support any of that, by design (it trades expressiveness for guaranteed linear-time matching, no catastrophic backtracking).
We switched to regexp2, a pure-Go engine that supports ECMAScript-mode regex. That single decision shaped a lot of what came after — including a real safety rail we had to add later (more on that below).
Proving equivalence, not just claiming it
We didn't want to say "it works" and mean "it compiled." So we built three independent layers of proof:
The original upstream test suite, unmodified, run against our port. We extracted every literal assertion from picomatch's own
test/*.jsfiles — not reimplemented tests, the actual ones — into a fixture set, and ran our Go port against them. Final result: 8,992 / 8,992 pass, 0 failures, across 32 files.Differential fuzzing. A harness that generates random glob patterns and paths, runs them through both the real JavaScript (via a Node oracle process) and our Go port, and diffs the results. A single 60-second run covers tens of thousands of cases.
Hash-verified test suite integrity. We committed
tests/original/(the copied, unmodified test files) with a SHA-256 hash manifest, so anyone can verify the suite we tested against is genuinely unaltered.
The bugs the fuzzer actually caught
This is the part that made the fuzzing effort feel real instead of decorative. Three genuine bugs surfaced this way, none of which the fixture suite alone had caught:
Astral-pattern mangling. Non-BMP Unicode characters (the ones outside the Basic Multilingual Plane — emoji, some CJK extensions) in patterns were getting mangled into replacement characters (U+FFFD) by our parser, where the original JS preserved them. 33 divergences in one fuzz run traced back to this. Root cause: our unescape strip logic was recombining lone-surrogate halves incorrectly. Fixed, re-verified back to zero for this category.
A slice-bounds panic. POSIX character-class patterns like [[:alnum:]...[:xdigit:]] crashed our Go port outright — slice bounds out of range. JavaScript's .slice() clamps silently past the end of a string; Go's slicing does not. We'd ported the logic faithfully, but faithfully porting an assumption that only holds in one language is still a bug.
Dead code from an off-by-one. A "strip consecutive /**" loop used a length-sensitive check (isUnit(rest, ch), which requires an exact single-character match) where the original JS just compared the first character of a longer string. The dead branch meant patterns like a/**/**/b produced doubled globstars in output. Small, easy to miss, real behavioral bug.
The edge case that ate hours: our own proof broke silently
Near the end, doing a final "pretend I'm a judge" pass — cloning the repo completely fresh and running every command a stranger would run — we found that our SHA-256 hash manifest didn't match the actual files on a clean clone. Not a small mismatch. A completely different hash.
Root cause: Windows' core.autocrlf was silently rewriting line endings (LF → CRLF) on checkout, changing file content — and therefore the hash — even though the test suite itself was byte-for-byte the same file logically. Our own integrity proof would have failed the exact verification it existed to enable.
Fixed with a .gitattributes rule forcing LF on that directory regardless of the checkout machine's Git config, then re-verified the fix on a genuinely fresh clone before trusting it. This was a good reminder that a "proof" artifact needs to be tested the same way the thing it's proving needs to be tested — from zero, not from your own already-configured machine.
The regex-timeout safety rail
regexp2 doesn't share V8's guarantees against catastrophic backtracking — a pathological extglob-star pattern could genuinely hang a match. We added an explicit match timeout (SetMatchTimeout, 250ms default) so a runaway pattern aborts instead of freezing the process. This shows up as a small, documented category of fuzz divergences (regexp2 times out on a pattern V8 eventually completes) — not a correctness bug, a deliberate safety trade-off we made and disclosed rather than hid.
The honest, slightly disappointing benchmark result
We expected — hoped — that eliminating the Node runtime would be a clean win across the board. It's not, and we reported that straight:
-
Cold start: Go wins, 6x. ~11.8ms median vs ~70.4ms for
node require('picomatch'). This is the real payoff of a static binary with no runtime to spin up. -
Throughput: Node wins, 4.1x. V8's native, JIT-compiled regex engine is simply faster at raw match throughput than
regexp2's interpreter — 5.86M matches/sec vs 1.44M. This isn't a port-fidelity issue (both sides agree on every match result across hundreds of thousands of differential cases) — it's a genuine regex-engine performance gap.
It would have been easy to only report the number that flattered us. We reported both, with methodology, min/median/max, and an honest note that one Go cold-start outlier (364.8ms, almost certainly antivirus/file-cache interference on first run) was disclosed rather than quietly dropped from the average.
The decision we'd take back — or at least revisit
We built our own CLI surface from scratch, since picomatch is a library with no command-line interface of its own. In hindsight, we designed the flag/exit-code contract fairly late, mid-build, rather than specifying it fully upfront — which meant a couple of small redesigns (like splitting "invalid input" and "internal error" into separate exit codes) happened after code already existed around the old contract. Nothing broke, but locking that contract down in writing before touching main.go would have saved a little churn. Small lesson, but a real one: for anything you're inventing rather than porting, write the spec first, even if it feels like overhead when you're eager to start coding.
What we'd tell someone doing this next
- Fuzz early, not at the end. Every real bug we found came from the fuzzer, not from writing more careful code.
- If you're building a proof artifact (hashes, checksums, logs), test that artifact from a clean environment too — it can break in ways your main build doesn't.
- Report the number that makes you look worse, if it's true. It's more convincing, not less.
Repo: github.com/sujitKrS04/pomatch — full test parity results, fuzz logs, benchmark data, and a 44-entry decision log are all committed, not just claimed.
Built for Port Mortem / Code Resurrection




Top comments (0)