→ / space advance · ← back
Formal methods · 6 minutes

Prove it, don't
just test it.

A bug every web dev has shipped, caught by a compiler that refuses to make human assumptions — and wired into a normal JS workflow.

Press → to break something.

19:00 · the code you'd write

The transfer function everyone writes

bank.js
function withdraw(balance, amount) {
if (amount <= balance)
return balance - amount;
return balance;
}
100 − 20 → 80 ✓
0 − 200 blocked ✓
tests green ✓

Two unit tests pass. These tests will never fail on this bug — the inputs they check are all fine. The failure comes from somewhere else…

A human reads amount as "a positive number".

The compiler reads it as "every integer that exists".

19:10 · the claim, not a test case

State the property for every input

Mini.lean — a model of bank.js, runs at build time, not in prod
-- a withdrawal never grows your balance:
theorem never_grows (balance amount : Int) :
withdraw balance amount ≤ balance

No test values. balance and amount are variables over all integers — infinitely many pairs, one claim.

what the model says the code does
#eval withdraw 0 (-50)
50 ← a zero balance became £50. Alice stole money.
19:11 · the compiler pushes back

The proof fails — and names the bug's address

lean Mini.lean
$ lean Mini.lean
error: omega could not prove the goal:
a possible counterexample may satisfy:
b ≤ -1 ← the amount is NEGATIVE
where a := balance, b := amount

Nobody typed −£50 as a test case. The failed proof derived that negative amounts are exactly where it breaks.

A failing test says "this one input broke". A rejected proof says the shape of every breaking input — which is why the fix is mechanical.

19:15 · one clause, now proved

Add what the human assumed

Mini.lean · fixed (and the same clause lands in bank.js)
def withdraw (balance amount : Int) : Int :=
if 0 ≤ amount ∧ amount ≤ balance
then balance - amount else balance
-- theorem unchanged:
theorem never_grows ... := by
unfold withdraw; split <;> omega
lean Mini.lean
$ lean Mini.lean
✓ no output — silence IS the proof
#print axioms never_grows
[propext, Quot.sound] ← no sorry: no fake proof

Proved for every pair of integers — and the axiom audit means a future "temporary sorry" fails the build instead of faking green.

Same move · now it's your codebase

A real guard in a real Node app

uploads.js — the guard everyone writes
if (abs.startsWith(UPLOAD_DIR)) // looks fine
srv/uploads/../.ssh/id_rsa

A .. buried mid-path. You'd unit-test a.png and ../../etc/passwd — nobody tests a .. in the middle.

the model, computed
-- careless guard accepts?
true
-- where does it really resolve?
["srv", ".ssh", "id_rsa"] ← OUT of the vault
-- the shipped guard (resolve FIRST)?
false ✓ rejected

Shout a filename. Whatever you say — the theorem already covers it.

The machine picked the witnesses

Every path of length ≤ 4, exhaustively

0
paths in the slice — every combination of
srv · uploads · .. · . · a · etc
0
escapes found for the
careless startsWith guard
0
leaks for the shipped
resolve-first guard

All six escapes share one shape: a .. right after the managed prefix — the exact condition the proof isolates. We never picked them.

The sweep is exhaustive for the slice. The theorem is what covers the infinity beyond it — by induction, not enumeration.

The workflow, in order

Where Lean runs — and where it doesn't

Lean never ships. It runs at build time, beside your tests — a linter that can read your intentions.

So what do you DO with a theorem, in a JS repo?

Three jobs, all at build time

1 · Spec

The theorem's statement is the code review: "whatever the guard accepts resolves inside the vault." Argue about the claim, not the vibes. If the JS matches the claim, the JS is safe.

2 · Pin

The witness the proof produced runs in node --test against the real function. Refactor path.resolve away and CI goes red — the theorem guards the code through its counterexample.

3 · Audit

lake build + #print axioms in CI: a sorry "proof" fails the build like a failing test. The certainty can't rot quietly between reviews.

node --test uploads.test.js — job 2, in the real suite
✔ accepts a real file inside the managed dir
✔ rejects the Lean counterexample (mid-path ..)
✔ rejects sibling dir "uploads-evil"
ℹ pass 4 · fail 0
The honest asterisk

What's certain, what's pinned, what's open

layerguarantee
theorems ↔ modelcertain — compiler-checked, zero sorry. True for every input in the model's universe.
model ↔ JSpinned, not proven — the same witness runs in Node; a refactor that diverges the code from the model fails CI. You still review the model by eye.
known gapsopen — symlinks (statSync follows links), POSIX only. Named in the README, not hidden by the green check.

Lean turns "is this safe?" into "is my model faithful?" — a small, attackable question instead of a vibe.

Take it home

It's one file, not a research project

your terminal, today
$ curl https://elan.lean-lang.org/elan-init.sh -sSf | sh # toolchain
$ lean Mini.lean # 9 lines, seconds
- run: (cd proofs && lake build) # one CI line, next to node --test

Unit tests check what you imagined.
Proofs check what's possible.

Model: proofs/Uploads.lean · pinned by uploads.test.js