Three Ways to Handle Multi-Version npm Packages in a Static Analyzer (We Picked the Hybrid)
By Caleb Gates
If you maintain a static analyzer that checks user code against a library of per-package error-handling rules, sooner or later one of those packages ships a major version that breaks your rules. The question is what you do about it. The wrong answer is duplicating the rule file. The wrong-wrong answer is shipping the old rule against the new version and giving advice that's quietly two years out of date. This is the story of how we picked the right answer for Nark. We tried the wrong one first, hit the wall on stripe v21, and then did the architecture we should have done from the start.
Quick Answer: To make a TypeScript static analyzer give correct error-handling advice across multiple major versions of the same npm package, the scanner must resolve the installed version at scan time and select a version-matched Profile. The cheapest way to maintain those version-matched Profiles long-term is Profile inheritance. A child Profile declares
extends: <parent>and overrides only the postconditions that diverged in the new major.
What is Nark? Nark is an open-source TypeScript scanner that checks projects against Nark Profiles for 165+ npm packages and reports missing error handling, unguarded calls, and resource cleanup gaps. Run it locally with npx nark or wire it into CI with the GitHub Action. It's AGPL-3.0, the corpus is CC-BY-NC-4.0, and both live on GitHub.
Today Nark shipped version-aware Profile selection plus Profile inheritance. A scan of a project on stripe v20 picks one set of contracts. A scan of a project on stripe v21 picks a different set. The v21 set inherits the v20 set and only overrides what stripe actually broke. This is the design narrative.
The problem: stripe v8 and stripe v21 are different packages
A Nark Profile is a YAML file that says, in effect, "here are the functions in this npm package that can throw, what they throw, and what your code needs to do to handle it." For stripe, the parent Profile covers 18 functions across 1,163 lines. It encodes things like:
stripe.charges.create()throwsStripeCardErrorwhen the card is declined; you must catch itstripe.charges.create()throwsStripeRateLimitErrorunder load; you must back offstripe.webhooks.constructEvent()throwsStripeSignatureVerificationErrorwhen the signature is invalid; you must return 400
Most of those contracts have been stable for years. But error classes are not stable forever. stripe v21 (shipped March 2026) reparented StripeInvalidGrantError under a brand-new StripeOAuthError base class and added six sibling OAuth error classes that did not exist in v20. v21 also added a runtime guard that throws when you call constructEvent() on a v2 webhook event payload, which is a different exception from StripeSignatureVerificationError.
A scanner that ignores all of this gives wrong advice. Specifically, it tells a v21 user to instanceof StripeRawOAuthError (which doesn't exist in v20, so the rule was written for v21) or it fails to flag the new wrong-method webhook throw (because the rule was written for v20). Either way it's lying.
The fix has two halves. The first: the scanner must resolve the actually-installed version at scan time. The second: the corpus must contain a Profile that matches that version.
The first half we shipped on 2026-06-09. The second half is what this post is about.
Three options on the table
When a package's behavior diverges across majors, you have three options for representing that in your corpus:
Option A: Fresh copy per version. packages/stripe/contract.yaml covers v8 through v20. packages/stripe-v21/contract.yaml is a brand new file covering v21+. Both are fully self-contained. This is the OpenAPI approach (/api/v1/ and /api/v2/ schemas live independently) and the semgrep-rule approach. It's also what ESLint does for major rule changes.
Option B: Inheritance via extends:. Same directory layout, but the child Profile declares extends: "../stripe/contract.yaml" and only writes the postconditions that diverged. Parent's unchanged postconditions are inherited automatically by the loader at scan time. This is the tsconfig pattern, the ESLint config pattern, the Renovate preset pattern.
Option C: Per-postcondition semver inside one file. Single stripe/contract.yaml covers every version. Each postcondition gets its own semver: field. At scan time the loader filters postconditions whose range satisfies the installed version. Maximum flexibility. No industry precedent.
We initially shipped A. We're now shipping B + A as a hybrid. We're not shipping C.
Why we initially shipped Option A: fresh copy
The first version-aware loader (committed 2026-06-09) only supported fresh copies. The architecture was deliberately minimal. A Profile is one file. Multiple files coexist per package. The loader reads each, sorts by semver specificity, and at scan time picks the one whose range satisfies the installed version. If none match, no Profile applies. Done.
The verification example was firebase-admin. Imagine firebase-admin v14 ships a wholesale rewrite of its auth error model: different class hierarchy, different error.code namespace, different retry semantics. A fresh copy is the right move. The parent Profile says nothing useful about the new model. You'd be inheriting wrong contracts. Better to start clean.
That mental model was right for firebase-admin and wrong for stripe. I didn't see it until I tried to write the stripe v21 fork.
Why Option A broke down on the first real fork
stripe's parent Profile is 1,163 lines covering 18 functions. v21's actual breaking changes amount to roughly 30 lines of new postconditions: six new OAuth error classes and one wrong-method webhook throw. Everything else stays.
A fresh-copy fork of stripe meant copying 1,163 lines, deleting almost nothing, and adding 30. Then drifting on every parent improvement forever. The 168-Profile corpus already has a maintenance bottleneck on the version axis. Making the unit of work 1,163 lines instead of 30 lines was going to make that bottleneck permanent.
The obvious workaround was to write a narrow stripe-v21 Profile, just the OAuth postconditions, no card-error or rate-limit, since the parent already covers those. But this is where the loader's "one Profile per package per version" rule turns into a footgun. A user running stripe v21 would have the loader pick stripe-v21 because the range matches. The parent Profile would not apply. And the narrow child has no card-error contract. So stripe.charges.create() without a try/catch in a v21 project would silently pass.
Worse than no fork. The fresh-copy approach was, in this specific case, generating wrong outputs in both shapes. Duplication if you copy the whole thing, silent regression if you don't.
The third time I caught myself thinking "okay then I'll just paste the 1,100 lines and accept the maintenance hit" was the moment I realized the architecture was wrong. The corpus needed inheritance.
Why we didn't pick Option C
The most expressive design is per-postcondition semver. One file per package, each postcondition declares its own range, the loader filters at scan time. Everything stays in one place. The version axis lives inside the file instead of across files.
We didn't ship this for three reasons.
First, no industry precedent. tsconfig, ESLint, Renovate, GitHub Actions composite actions, Tailwind presets. Every config-driven tool I could think of that supports cross-version inheritance picked extends:, not per-field semver. When every other team in the space made the same choice, your bar for going a different direction needs to be very high. Mine wasn't.
Second, readability collapses at >2 splits. Imagine the stripe Profile when v22, v23, v24 all have small overlapping behavioral diffs. Every postcondition body gains a semver field. Some of those fields fork into nested arrays. The file becomes a programming language. People stop reading it.
Third, runtime cost. The current loader pays one semver.satisfies() call per package per scan. With Option C it would pay one per postcondition per scan. Call it 500 for a typical project. Negligible in isolation, but it adds up as the corpus grows.
extends: keeps the version axis at the file level, where reviewers expect to find it, where diffs are readable, where the runtime cost is bounded.
The hybrid we shipped (2026-06-10)
The loader now supports both modes. extends: is opt-in per Profile. A Profile can declare it or omit it.
A Profile that omits it is a fresh copy. Same behavior as the 2026-06-09 loader. This is the right mode when the new version's error model is meaningfully different from the parent, when inheriting more would be inheriting wrong. Reserved for major rewrites.
A Profile that includes extends: inherits its parent's functions[] and merges its own overrides on top. The merge is structured:
functions[]deep-merges byfunction.name. If the parent hascharges.createand the child hascharges.create, the child wins. If the parent has functions the child doesn't mention, they pass through. If the child has new functions the parent doesn't have, they append.- Inside a function,
postconditions[]/preconditions[]/edge_cases[]merge byid. Child overrides matching ids; new ids append; unmentioned parent ids pass through. detection:rules and the package-levelsemver:field are child-wins. No deep merge for detection; it's almost always all-or-nothing.
The decision rule for "extend semver vs. extends-fork vs. fresh-copy fork" lives in bc-version-drift, our periodic corpus-freshness audit. The short version:
- Extend the existing Profile's semver range when the new major has no error-handling changes. One-line bump.
- Extends-fork (the new default for forks) when error-handling diverged but ≥50% of parent postconditions still apply. New child file with
extends:. - Fresh-copy fork when the parent's error model is mostly wrong for the new version. Rare.
The heuristic I tell people: prefer extends-fork unless you can name three parent postconditions the new major actively invalidates. If you can name three, fresh-copy. If you can't, extends-fork.
What it looks like in practice
The real stripe-v21 Profile is 200-ish lines including comments. The structural part is roughly this:
package: stripe
semver: ">=21.0.0"
contract_version: "1.0.0"
maintainer: "corpus-team"
last_verified: "2026-06-09"
status: production
evidence_quality: confirmed
extends: "../stripe/contract.yaml"
functions:
- name: constructEvent
postconditions:
- id: wrong-webhook-parsing-method
condition: "constructEvent() called with a v2 event-notification payload"
throws: "StripeError. v21 added an explicit guard."
required_handling: "Use parseEventNotification() for v2 thin events."
sources:
- "https://github.com/stripe/stripe-node/pull/2618"
severity: error
- name: token
import_path: "stripe"
description: "stripe.oauth.token(). Exchange OAuth code for access token."
postconditions:
- id: oauth-invalid-grant
condition: "Code expired, used, or wrong mode"
throws: "StripeInvalidGrantError (extends StripeOAuthError)"
satisfying_patterns:
- instanceof: "Stripe.errors.StripeInvalidGrantError"
- instanceof: "Stripe.errors.StripeOAuthError"
sources:
- "https://github.com/stripe/stripe-node/blob/v21.0.0/src/Error.ts"
severity: error
Two new functions, one new postcondition on an existing function, one extends: line. The merge brings forward 18 functions and all of their postconditions from the parent. At scan time:
stripe@20.0.0 → parent Profile (>=8.0.0 <21.0.0) selected
→ fires card-error on stripe.charges.create() without try/catch
stripe@21.0.0 → v21 Profile (>=21.0.0) selected
→ merged: 18 inherited functions + 2 new + 1 overridden postcondition
→ fires card-error (inherited) AND oauth-invalid-grant (new)
→ also fires wrong-webhook-parsing-method on constructEvent
This is the behavior we verified end-to-end before shipping. stripe v20 and stripe v21 share card-error coverage because the v21 Profile inherits it. stripe v21 alone fires the OAuth contracts because they live only in the child. Neither version silently drops anything.
What we deliberately didn't build
A short list of things that came up during design and got cut for proportionality. Most product debt is a no that should have been a no earlier.
Per-postcondition semver (Option C). Covered above. Wait for demand. If a single package ever genuinely needs five overlapping version splits within one file, we'll revisit.
Cross-package extends. A Profile cannot inherit from a different package's Profile. Every Profile has exactly one package: field and that field must match the parent. This avoids a whole category of accidental cross-pollination.
Detection-block deep merge. If a child declares detection:, it wins wholesale. The parent's class names, factory methods, and import patterns are not merged in. This was a coin flip. I'd revisit it if a future fork actually needs additive detection. But the simpler rule was cheaper to ship and reason about.
"Delete this parent postcondition" syntax. Children can override a parent's postcondition by id (matching id, child wins) but cannot remove one outright. If a future major genuinely removes a postcondition, the right move is to fork the entire function override-style or use a fresh-copy fork for that package. Adding __deleted_postconditions: [...] was tempting and would have been wrong. It's the kind of feature that gets used once a year and confuses readers the other 364 days.
Multi-level inheritance depth cap. The loader allows up to 5 levels of extends: chain and detects cycles. Five is well above any realistic version-fork depth. If someone ever ships a grandchild Profile, the merge composes naturally.
Why this matters for the corpus economy
168 Profiles today. Realistically maybe 10–20 will ever genuinely diverge enough across a major to warrant a fork. With fresh-copy-only, those 10–20 forks would each be a 500–1500-line file that drifts from its parent forever. With extends:, those same 10–20 forks are 30-line files where the inheritance does the work.
The maintenance cost per fork is now low enough that nobody dreads it. Which means when stripe ships v22, somebody actually writes the fork instead of leaving the parent's semver capped at <17.0.0 and walking away. That's exactly what had happened to the existing stripe Profile before this work. The parent's range had been frozen at <17.0.0 for two years, even though stripe v17, v18, v19, and v20 introduced zero error-class changes. We just widened it to <21.0.0 based on the changelog evidence, and added the v21 fork.
We built Nark to give correct error-handling advice for the actual code people are writing. Correct means version-correct. The architecture has to make version-correctness affordable, or version-correctness doesn't happen.
Now it's affordable. Now it can happen.
If you want the implementation details, the loader is at src/corpus-loader.ts in the Nark repo. The merge function is exported as mergeContracts() and has 14 unit tests covering override-by-id, append-new-id, inherit-unchanged, missing-parent-error, circular-extends-error, package-mismatch-error, path-escape-error, and multi-level chains. The canonical worked example is packages/stripe-v21/contract.yaml in the nark-corpus repo. Both repos are open source. PRs welcome.