@premaanshvyas/lockstep
v0.5.0
Published
Finds where your code assumes a third-party API can only return values it already knows about. Runs locally, executes nothing, uploads nothing.
Maintainers
Readme
lockstep
Finds where your code assumes a third-party API can only return values it already knows about.
npx lockstepRuns locally. Reads your source and your installed SDKs. Executes nothing, uploads nothing, needs no account.
The problem
Stripe adds a new subscription status. Your code has this:
export function shouldRevokeAccess(sub: Stripe.Subscription): boolean {
return sub.status === 'canceled' || sub.status === 'unpaid';
}A paused customer now keeps full access without paying. Nothing crashes. No error is logged. Every test passes, because your tests never had a paused subscription in them — which is exactly why nobody caught it.
Vendors know this happens. Stripe's docs tell you to handle unfamiliar values gracefully. Plaid's docs say they add enum values at any time with no version bump. Nobody checks whether anyone actually does it.
What it does
- Inventory — which vendor SDKs are installed, at which version.
- Vendor truth — reads the type declarations inside
node_modulesand extracts every fixed list of values the SDK declares. - Assumptions — runs the TypeScript compiler over your code and finds every place it treats one of those values as a closed set.
- Compare — subtracts one from the other, ranked by consequence.
● src/billing/access.ts:9
handles 3 of 8 values of Subscription.Status · no fallback
missing: canceled, incomplete, incomplete_expired, paused and unpaid
→ 5 unhandled values fall out of the switch and return undefined
Stripe's own types declare no fallback for this fieldFour layers, and an honest ledger
Type information runs out. Rather than quietly skipping what it can't resolve, lockstep reports it.
| Layer | Catches | Confidence |
|---|---|---|
| Type checker | typed TypeScript — exact call sites | certain |
| AST shape | untyped JavaScript, comparisons, switches | probable |
| HTTP scan | raw fetch() calls that skip the SDK | probable |
| Ledger | dynamic access, unparseable files | reported, never dropped |
Every run ends with what was understood and what wasn't:
Coverage 5 of 7 decision points understood · 4 of 4 files read
3 resolved by the compiler, 1 matched by shape, 1 raw HTTP call
2 could not be resolved:
2 — indexed by a vendor value computed at runtimeA tool that tells you what it couldn't see is a tool you can believe when it says it found something.
Usage
npx lockstep [path]
--inventory where this code calls each vendor, and which response
fields it reads — facts only, no findings
--by <mode> inventory grouping: file | operation
--file <path> inventory: only files whose path contains this
--sort <mode> inventory operations: name (default) | calls
--all include low-severity findings
--json machine-readable output
--fail-on <level> exit non-zero at high | medium | low (for CI)
findings only — the inventory never affects the exit code
--helpIdentical input bytes produce identical --json output bytes, so two commits can be
diffed. If a limit stops the walk short, the report says so in its first lines and names
every file it skipped. Exit codes: 0 normally, 1 no node_modules (or --fail-on),
2 bad path, 3 the project needs more heap than this process has — in which case it
prints the exact command to run rather than producing a partial answer.
The inventory
--inventory prints the map: every place the code invokes a vendor, every field
of a vendor response it reads, and — in the same list, at the same indent — every
place something could not be resolved, with the reason.
Stripe [email protected]
app/api/stripe-webhook/route.ts
:5 new Stripe() → Stripe
:19 stripe.webhooks.constructEvent() → Event
:24 .data read Event
:26 .type read Event
:36 subscription.status ? receiver `subscription` was
widened to `any` at line 24
4 calls · 3 operations · 4 fields read · 9 unresolvedNo severity, no ranking, no advice. Calls are reported where they are written: a local function that forwards a vendor call is counted once, at its own line, and its callers are not counted — an identification tool says where the call is, not where control might eventually reach one.
--json carries the same records with no truncation, plus vendorRoots, the
list of directories provenance was resolved against.
Vendors
Any third-party package your code calls, not a fixed list. A hand-written registry of 129 packages carries the things that cannot be derived — hostnames, ambient globals, a display label — for Stripe, OpenAI, Anthropic, Twilio, Plaid, Slack, Square, SendGrid, Resend, Shopify, and about 120 more across AI, payments, comms, auth, data, observability and developer tooling. Everything else is discovered: a package whose own shipped code both names an HTTP endpoint and sends HTTP requests is read in full, exactly like a curated one, and the evidence that promoted it is printed beside every row so you can check it.
Every other package your code imports is still listed by name with a file count. Nothing is
omitted; --json carries every package in every tier under packages, and --all expands the
summarised ones in the terminal.
The value lists are never hardcoded — they are read from whichever SDK version the project has installed, so this does not go stale when a vendor ships new values.
What it deliberately does not do yet
Run your code, write fixes, or open pull requests. Each of those needs something you have to grant it. This phase asks for nothing.
Development
npm install
npm run build
node dist/cli.js /path/to/projectMIT.
