Most advice for a first open-source contribution sends you to the good first issue label. I stopped doing that months ago. Those issues are either already half-claimed, or they're docs typos that don't teach you anything, or the maintainer left them open because they're genuinely annoying to fix.
Here's what I do instead, and it's merged into raylib, NestJS, es-toolkit, langchain4j, bat, and a pile of others in the last few weeks.
I read the repo's recently merged bugfix PRs and look for the bug the fix forgot.
the idea in one line
When a maintainer fixes a bug, the exact same bug shape often survives somewhere the diff never reached — a parallel function, a neighboring file, the other branch, the float version of the int helper. The fix landed. The twin didn't.
I call it a sibling-leftover. It's not a different bug. It's the same wrong expression, the same missing guard, the same off-by-one, sitting in a copy-pasted sibling that the original PR happened not to scroll past.
Why this is such a good first contribution:
- It's provably correct. You're not inventing a fix. A maintainer already reviewed and merged the reference fix. You're completing a pattern that already passed review.
- It's trivial to review. The PR is tiny and the justification is one sentence: "same fix as #NNN, applied to the sibling it missed." A reviewer can verify it in thirty seconds.
-
It teaches you the codebase faster than a tutorial. To find the twin you have to actually read how the project is structured, which is the thing the
good first issuepile never makes you do.
the method, 4 steps
1. Find a recently merged bugfix. Sort the repo's merged PRs by date. Filter for bug, fix, correctness, regression, or changelog lines that start with "Fixed". You want a fix that's small and structural — a guard added, a wrong key corrected, a comparison flipped. Skip big feature work.
2. Read the diff and ask "where else is this shape?" This is the whole skill. The fix changed one function — does the project have a parallel one? A float variant next to the int variant? A runtime-vapor next to a runtime-core? A 12.x branch next to 13.x? Copy-pasted handlers are the richest hunting ground, because the copy inherited the bug and its apparent correctness.
3. Verify in both directions. This is the step people skip, and it's the one that keeps you honest:
- the sibling actually has the same flaw — not an intentional difference;
- your mirrored fix matches the merged fix's intent, not just its text.
Read the PR discussion, not only the diff. If the maintainers argued about the behavior, the "sibling" might be a deliberate divergence, and your PR would be wrong.
4. Open a minimal PR that references the original. One-line body: "Same fix as #NNN, applied to the_sibling which wasn't covered." Link the elder PR. Keep the diff to the smallest change that mirrors it. Don't reformat the file. Don't sneak in a refactor.
real examples (all merged)
These are mine, all landed in the last few weeks. I'm naming the PR numbers so you can read the diffs yourself.
raylib, six siblings at once. A lower-bound guard (gamepad >= 0) had been added to one input function. Five other functions in rcore took the same gamepad index with no lower-bound check. Same shape, same fix, six call sites. It merged as a single follow-up PR (#5938, following #5937). This is the cleanest version of the pattern: one fix, a row of identical siblings, one PR to finish the row.
NestJS, a hooks scanner that skipped the wrong thing. A fix taught scanForClientHooks to skip function-valued members. There was a structurally identical scan that hadn't gotten the same treatment. The PR title is literally (match #17188) (#17207) — the reference fix is right there in the name.
es-toolkit, the same edge case in unset. A fix stopped treating an own literal dotted key as a deep path. Its sibling code path had the identical assumption. Merged as (match #1775) (#1808).
langchain4j, a filter escape missing in one backend. LIKE wildcards were being escaped in some containsString filters but not the Milvus one. Merged as (match #5522/#5553) (#5600) — mirroring not one but two prior fixes.
indicatif, the float twin of an int helper. A formatting correction landed on HumanCount. Its direct float sibling, HumanFloatCount, had the same bug and the original fix hadn't touched it (#816).
bat, the honest one. A usize underflow was fixed in one path with saturating_sub; I sent the same fix for --list-languages at a narrow terminal width (#3812). What I like about this one: the maintainer's changelog explicitly noted the mirror relationship to the earlier fix. When a project documents "this is the sibling of #NNNN," they're handing you the next contribution for free.
where it breaks (read this part)
I want to be straight about the failure modes, because the method looks easier than it is.
The biggest one: the sibling is intentionally different. Two functions can look like twins and behave differently on purpose. equal and not_equal are not the same fix with a sign flipped — invert that carelessly and you've shipped a real bug into a security-relevant path. The only defense is reading the discussion on the original PR until you understand why the fix was the right call, not just what it changed.
The second: not every maintainer wants a flurry of follow-ups, and some repos are wary of drive-by contributions right now. The thing that earns the merge is the opposite of a flurry — one tiny, obviously-correct change, with the reference PR linked, that respects the reviewer's time. If you can't write the justification in one sentence, you don't understand the fix well enough to send it yet.
The third, on me: don't fabricate the symmetry. If you go looking for a sibling and there genuinely isn't one, there isn't one. Padding a PR to make the pattern fit is how you lose a maintainer's trust on your first interaction. A good first issue typo is more honest than a forced sibling.
i'm building a dataset out of this
Every time I find one of these, I log it: the repo, the PR URL, the language, the file, the bug class, the before/after pattern, where the sibling was, and how long it stayed broken. The timing is the interesting part — siblings stay unfixed anywhere from the same afternoon (when one human goes looking) to roughly six months (when nobody does).
It's small right now, 79 verified pairs across twelve languages, so treat it as a seed and a schema, not a benchmark. The point is to have real, labeled examples of "find the structural twin of this fix" instead of synthetic ones, for anyone who wants to build tooling that does this automatically. I'll post the data once it's cleaned up for release.
it's out now — the data is public: https://github.com/greymoth-jp/sibling-leftover-dataset
If you've ever merged a fix and then noticed, a week later, that you missed the same bug one function over: that's the whole thing. The method is just doing that on purpose, for someone else's repo, before they notice.
greymoth · field notes on the Japan-shaped holes in global software · github.com/greymoth-jp · proof dashboard
Top comments (0)