Footguns - pannous/warp GitHub Wiki

Footguns

Footguns in other programming languages and how they are avoided in Warp

Solved

See Solved elsewhere for comparisons with other languages. You may not be aware, but in other languages, you might stumble upon these questions with wrong answers. .1 + .2 == .3 ? Warp : see below

How to read the entries: offending language: one-liner β†’ its wrong answer; then what Warp does. Every Warp answer below was produced by running the real compiler (target/debug/warp eval …, WASM GC round trip):

  • probes/footguns/footguns.sh evaluates all cases in probes/footguns/cases.warp, output in probes/footguns/results.txt
  • tests/probe_footguns.rs pins the Solved entries as is! tests; its #[ignore = "next"] tests are the NOT YET entries with a clear intended answer

Only entries whose Warp answer was verified are listed here; everything else is under ## Events, signals and channels Verified 2026-10-06 with warp eval (signal, notes/signals.md, notes/system_signals.md).

Event emitted before its listener exists

Node: emit in a constructor before the caller's .on(…); DOM: img.onload set after img.src β†’ the event is missed
Warp: handlers are program-wide, like function definitions: emit ping; on ping { print "got it" } prints got it.

Handlers capturing a loop variable

JS var: for (var i…) el.onclick = () => use(i) β†’ every handler sees the last i
Warp: a handler made in a loop keeps its own iteration's variable (P70c): fs = 0; for i in 1 to 3 { on click { fs += i } }; emit click; fs β†’ 6.

Stale state in handlers

React: a useEffect listener reads the state of the render that subscribed it
Warp: a handler reads variables when it runs: x = 1; on ping { print x }; x = 2; emit ping prints 2.

A listener that writes what it watches

Vue: "Maximum recursive updates exceeded"; React: setState in an effect on that state β†’ endless loop
Warp: a listener's own writes do not run it again: x = 0; on change x { x = x + 1 }; x = 1; x β†’ 2.

Removing a listener while the event is dispatched

Hand-written emitters that splice the array they iterate β†’ the next listener is skipped
Warp: n = 0; h = on tick { n += 1; remove h from listeners of tick }; on tick { n += 10 }; emit tick; emit tick; n β†’ 21: the second listener runs both times.

Removing an anonymous listener

DOM: removeEventListener("click", () => f()) or f.bind(this) β†’ removes nothing, silently
Warp: only a named handler can be removed, by its name: h = on alarm {…} … remove h from listeners of alarm (clear listeners of alarm removes all).

Synchronous or asynchronous dispatch?

Node emit and DOM dispatchEvent are synchronous, promises are not β†’ code assumes the wrong order
Warp: one rule, synchronous in declaration order: on e { print "handler" }; print "before"; emit e; print "after" prints before handler after. An event emitted in a task reaches the program's handlers at its next check point (await, loop start, end of the run).

A change that is no change

Many watchers fire on every assignment
Warp: on change x runs only when the value differs (x = 1; on change x {…}; x = 1; x = 2 runs once); on set x runs at every write. once e {…} runs at the first emit only.

Events or errors?

Node: emit("error") without a listener throws; Common Lisp signal vs error; one word for both makes adding a handler somewhere change what every raise means
Warp (P163): emit alarm{…} (or send alarm{…} without to) sends an event: the on alarm handlers run and the program goes on; an emit nobody handles does nothing. raise and throw are errors only: on alarm {…} never catches raise alarm, and says so ("raise alarm is an error, emit alarm sends the event"). fire, trigger and signal work as emit with a note. A task failing that nobody awaited ends the run with its error: go { 1/0 }; sleep(50 ms); 0 β†’ Error("task go block 1: divide by zero").

Handlers share one mutable event

DOM: one listener changes the event object, the next sees the change
Warp: each handler gets the event as emitted: seen = 0; on e { event.x = 2 }; on e { seen = event.x }; emit e{x:1}; seen β†’ 1.

A change through a method is not seen

Vue 2: arr[i] = v is not reactive; React: state mutated in place does not re-render
Warp: x.add(v) (append, push, insert, pop, remove) is a write of x: n = 0; x = [1]; on change x { n += 1 }; x.add(2); n β†’ 1.

Misspelled event names

Node: emitter.on("conect", …) β†’ never fires, silently
Warp: on conect {…} with nothing emitting conect is a warning, "nothing emits conect, so this handler never runs; did you mean connect?", and the handler is dropped. Page and system events (on click, on interrupt) come from outside and stay quiet.

What a signal handler may do

C/POSIX: a signal handler may only call async-signal-safe functions (printf, malloc β†’ deadlock)
Warp: on interrupt {…} is ordinary code, run at the program's next check point (a loop start, a sleep cut short), never in the middle of a statement (tests/control/test_system_signals.rs).

Level or edge: when does a condition watcher run?

Watchers that fire at every write while the condition holds spam their handler, also for writes that changed nothing
Warp: whenever cond {…} runs each time the condition becomes true (P156, decided 2026-10-06): x = 0; n = 0; whenever x > 5 { n += 1 }; x = 6; x = 7; x = 3; x = 8; n β†’ 2; also inside functions, as a named listener and on shared values (test_event_footguns.rs). The other behaviours have their own forms: What to write for the other behaviours (each checked with warp eval):

you want write with x = 6; x = 7; x = 7; x = 3; x = 8
each time the condition becomes true whenever x > 5 {…} runs 2 times (6, 8)
at every change while it holds on change x { if x > 5 {…} } 3 times (6, 7, 8)
at every write while it holds on set x { if x > 5 {…} } 4 times (6, 7, 7, 8)
repeatedly, as long as it holds on every 1 second { if x > 5 {…} } (a timer), or a loop while x > 5 {…}

Signals: hidden and conditional dependencies

Solid: a read after an if or inside a helper is not tracked until it runs once; Vue: a destructured ref loses reactivity
Warp: dependencies are found at compile time, through := values and called functions: g() := x * 2; x = 1; n = 0; whenever g() > 5 { n += 1 }; x = 3; n β†’ 1; d := if flag then x else y follows flag, x and y. A plain copy y = x is a value and does not follow x; y := x does.

Signals: derived values and listeners in functions

Warp: a := value read in a method's argument is its value (a = 1; b := a * 2; log = []; on change a { log.add([b, a]) }; a = 2; log β†’ [[4 2]]); a listener made in a function changes the program's variables and subscribes at every call, loops included (n = 0; watch(s) := on change s { n += 1 }; x = 1; for i in 1 to 3 { watch(x) }; x = 2; n β†’ 3).

Signals: a new object with the same content

React, Vue: a fresh object with equal fields counts as a change
Warp: on change compares by value: p = {age: 1}; n = 0; on change p { n += 1 }; p = {age: 1}; n β†’ 0.

Events emitting each other forever

Any emitter: a handler emits b, b handler emits a β†’ stack overflow at run time
Warp: handlers that emit each other without a condition are a compile error naming the cycle: on a { emit b }; on b { emit a }; emit a β†’ "endless emit loop: on a emits b, on b emits a; emit one of them under a condition". An emit under if or in a once handler breaks the cycle.

Listener leaks

Node: MaxListenersExceededWarning; React: an effect without cleanup subscribes at every render
Warp: a handler in a block subscribes each time the block runs (by design); in a loop it gets a got-it note: n = 0; for i in 1 to 100 { on tick { n += 1 } }; emit tick; n β†’ 100, with "a handler in a loop subscribes once per pass: each emit tick runs all of them; subscribe before the loop unless that is meant".

Channels: deadlocks, closing, ranging

Go: "all goroutines are asleep - deadlock!", send on a closed channel panics, closing twice panics, range ch over a channel nobody closes hangs
Warp (P155, native build): ch = channel(); go { ch.send(42) }; ch.receive() β†’ 42; for v in ch {…} receives until the sender's ch.close(). A receive or send while no task is left to answer is an error naming it ("ch.receive() waits forever: no task is left to answer it"), a send on a closed channel and a second close are errors, tasks still waiting when the program ends stop. chat = channel "chat" is the machine-wide channel with the same words. The playground runs local channels too (SharedArrayBuffer and Atomics.wait between its task Workers).

Signals: listening to an expression

Vue: watch(() => b + c, …) needs a getter, a plain b + c is watched once
Warp: on change b + c {…} watches the expression as a := value of its own: b = 1; c = 2; n = 0; on change b + c { n += 1 }; b = 5; c = 2; b = 7; n β†’ 2 (on set b + c runs at every write).

Coalesced signals

Unix: two SIGCHLD before the handler runs β†’ one handler call
Warp: on set of a shared value runs once per write, also for writes a task made between two polls (each shared value counts its writes): shared n = 0; count = 0; on set n { count += 1 }; job = go { for i in 1 to 5 { n += 1 } }; await job; sleep(50 ms); count β†’ 5. on change compares values and still sees the latest one.

NOT YET.

Integer division

C, Java, Go, Python 2: 7/2 β†’ 3
Warp: 7/2 β†’ 3.5. / is always division; truncation must be asked for. (test_division_is_not_truncating) Floor division is /!: 7 /! 2 β†’ 3, x /!= 2 divides in place. // is always a comment: a 7//2 meant as Python's floor division warns naming /!, and warp fix rewrites it. (test_int_division)

Sum of quotients truncated

Warp's own bug, fixed: 1/4+1/4 β†’ 0 while 1/4+1/4 == 0.5 β†’ 1; the sum's result type was inferred as Int and truncated on return.
Warp: 1/4+1/4 β†’ 0.5: type inference knows / never yields an Int (analyzer::arithmetic_kind). (test_sum_of_quotients_is_not_truncated)

Decimal fractions

Python, JS, Java, C, …: 0.1 + 0.2 == 0.3 β†’ false (0.30000000000000004), 1/3*3 == 1 β†’ false in some cases
Warp: decimals and divisions are exact (decision exact-decimals, 2026-10-10): 0.1+0.2 β†’ 0.3, 0.1+0.2 == 0.3 β†’ yes, 1/3 + 1/3 β†’ 2/3, 1/3*3==1 β†’ yes, 2^-2 β†’ 0.25. Floats come only from float sources: sin and other transcendental functions, sqrt of a non-square, fractional powers, host values (mouse_x, time, audio samples), as float, the suffix f (1.0f) and e-notation (1.5e-7). A float spreads to what it meets, only where it meets it: 0.1f+0.2 β†’ 0.30000000000000004, mouse_x + 0.5 is a float; == warns only when a float is involved, fix: use β‰ˆ. Footgun kept small: exact math is slower than f64 in a hot loop, so a host value or as float puts a pixel or sample loop on floats (a finger-paint stroke 3.3 β†’ 0.3 ms). An Int handle may point at a $Ratio next to the $BigInts, so integers keep the i64 fast path and only the slow paths see ratios (src/wasm_emitter/exact.rs). x /= y keeps an integer x an integer (truncating), as int truncates a ratio.

NaN and infinity

IEEE 754 everywhere: NaN == NaN β†’ false, 1/0 β†’ Infinity, sqrt(-1) β†’ NaN, and NaN poisons all later math quietly.
Warp: exact numbers (ints and ratios) raise divide by zero, an error try catches: 1/0, -1/0 and 0/0 are Error("divide by zero"), try 1/0 else 0 β†’ 0 (decision footgun-divzero, P66). A computed IEEE float keeps IEEE: x = 1.0 as float; x/0 β†’ inf, x = 0.0 as float; x/0 β†’ NaN, and NaN never equals itself (y == y β†’ no). sqrt(-1) β†’ β…ˆ, the exact imaginary unit, not NaN.

Octal literals

C, Java, sloppy JS: 010 β†’ 8
Warp: 010 β†’ 10. A leading zero never switches the base; bases are explicit (0x10 β†’ 16). (test_leading_zero_is_not_octal)

Loose equality

JavaScript, PHP: 1 == "1" β†’ true (and "0" == false β†’ true)
Warp: 1=="1" β†’ false. No implicit string↔number coercion in comparisons. (test_no_loose_equality)

Integers silently becoming doubles

JavaScript, JSON parsers: 9007199254740993 β†’ 9007199254740992
Warp: 9007199254740993 β†’ 9007199254740993; integers are 64 bit, not doubles. (test_integers_beyond_double_precision)

32 bit overflow

Java, C#, C (int): 2147483647 + 1 β†’ -2147483648
Warp: x=2147483647;x+1 β†’ 2147483648. (64 bit: see Integer overflow below.) (test_integers_beyond_double_precision)

Power associativity

Excel, some calculators: =2^3^2 β†’ 64
Warp: 2^3^2 β†’ 512, right associative like mathematics. (test_power_is_right_associative)

Automatic semicolon insertion

JavaScript: x = 1⏎-1 β†’ x == 0 (no semicolon is inserted before a line starting with -; likewise return⏎{a:1} β†’ undefined)
Warp: x=1⏎-1⏎x β†’ 1. A newline ends the statement. (test_newline_ends_statement)

Braceless call grabbing too little

Ruby, Haskell-style juxtaposition (wiki/Bad.md feared it): fibonacci number-1 read as (fibonacci number)-1 β†’ infinite recursion
Warp: f := it*10; f 3-1 β†’ 20, the call takes the whole argument expression f(3-1). (test_braceless_call_takes_whole_argument) As an operand the call stays inside it: 1 + count xs is 1 + (count xs).

Parameter shadowing

Many languages accidentally read the outer variable when a parameter has the same name.
Warp: x=1;f(x):=x*2;f(5)+x β†’ 11, the parameter shadows, the outer x is untouched. (test_parameter_shadows_outer_variable) A local variable or parameter named like a library word hides it only in its own function: f() := { words = ["a" "b"]; words#1 } gives "a", while words("a b") elsewhere still splits the text.

"0" is falsy

PHP, Perl: if ("0") β†’ false
Warp: if "0" {1} else {2} β†’ 1; only the number 0 is falsy: if 0 {1} else {2} β†’ 2. (test_zero_string_is_truthy)

Undefined variables

JavaScript: a + 1 β†’ NaN; a = 1 in sloppy mode silently creates a global; Perl/PHP: 0/warning
Warp: a+1 β†’ compile error Undefined variable: a. (test_undefined_variable_is_an_error; an Error value with its line and column)

Unary minus and power

Excel, bash: =-2^2 β†’ 4
Warp: -2^2 β†’ -4, as in mathematics and Python; unary minus binds weaker than ^ but tighter than * (-2*3 β†’ -6, (-2)^2 β†’ 4). (test_negative_power_precedence, test_negative_literals_after_power_fix)

not vs comparison

C: !1 == 2 β†’ 0 ((!1) == 2)
Warp: not 1==2 β†’ true, also !1==1 β†’ false: not binds weaker than comparisons, tighter than and/or (not 1==2 and 2==2 β†’ true). (test_not_binds_weaker_than_comparison)

& and | vs comparison

C: 3 & 4 == 4 β†’ 1 (3 & (4==4)); Python: False ((3&4)==4)
Warp: & and | are the logical and/or of truth values and combine the bits of whole numbers (wiki/&.md), and a symbol form next to an ungrouped comparison is an error, neither C's nor Python's reading is picked silently: 3 & 4 == 4 β†’ ambiguous: `&` mixed with a comparison …; fix: 3 & (4 == 4) or (3 & 4) == 4, also 1==1 | 2==3. Grouped (1==1) | (2==3) and the word forms 1==1 and 2==2, 1==2 or 2==2 β†’ true. (test_logic_binds_weaker_than_comparison, test_symbolic_logic_next_to_comparison_needs_grouping)
Decision: diagnostic for the ungrouped mix (since operator-shall, 2026-10-13, & of whole numbers is bitwise; alternatives then: & logical and weaker than ==, answer true silently as in C; & bitwise and tighter than ==, answer false as in Python, breaks wiki/&.md and square & print composition).

Chained comparison

C, JS: 3 > 2 > 1 β†’ false (true > 1)
Warp: 3>2>1 β†’ true, 1<3<2 β†’ false: mathematical chaining as in Python, a<b<c means a<b and b<c (also with <= >=); == and != never chain with them (user, 2026-10-03): 1<2==2 is (1<2)==2 β†’ false, 1<2 == 2<3 compares the two results and 1==1==1 is refused as ambiguous. Explicit grouping does not chain: (3>2)>1 β†’ false. (test_chained_comparison, test_equality_never_chains)

Assignment in a condition

C, JS: if (x = 2) β†’ always true, x overwritten
Warp: x=1;if x=2 {3} else {4} β†’ 4 and x stays 1; if 1=2 {3} else {4} β†’ 4. Inside an if/while condition = is comparison (wiki/Bad.md "assignment, declaration, comparison"); a {block} or the : branch inside the condition assigns again. (test_equals_in_condition_compares)

Increment

C: i++ + i++ β†’ undefined behaviour
Warp: x=1;x++;x β†’ 2, i=1;++i β†’ 2, i=3;--i;i β†’ 2. As wiki/equality.md says, ++ is immediate, so i++ and ++i are the same. (test_increment_changes_variable, test_prefix_increment)

Braceless call as operand

Warp: f := it*10; 1 + f 3 β†’ 31: a braceless call is a valid operand of + - * / (2 * f 3 β†’ 60). (test_braceless_call_as_operand)

Braceless argument extent

Ruby: square 3 + square 3 β†’ square(3 + square(3)); Haskell: f 3-1 β†’ (f 3)-1
Warp: a braceless argument takes arithmetic and ranges and stops at comparisons and logic, as operand the same as at statement level: f := it*10; 1 + f 3-1 β†’ 21 (was 30, (1 + f 3) - 1), 2 * f 3-1 β†’ 40, f 3-1 > 15 β†’ true, y = sum 1..4 β†’ 6. A second braceless call inside the argument is an error with both readings as fix-it (wiki/precedence.md "Ambiguous mixing of function and operator"): square 3 + square 3 β†’ ambiguous braceless call …; fix: square(3) + square(3) or square(3 + square(3)). The recursive case of wiki/Bad.md fib := it<2 ? it : fib it-1 + fib it-2 (was Undefined variable: it) β†’ fix: fib(it - 1) + fib(it - 2) or fib(it - 1 + fib(it - 2)); a function defined with := takes an identifier argument in any position: fac := it<2 ? 1 : it * fac it-1; fac 5 β†’ 120. (test_braceless_argument_extent_is_consistent, test_braceless_call_in_argument_is_ambiguous, test_braceless_recursive_call_takes_identifier_argument)
Decision: the argument extends over arithmetic and a nested braceless call in it is a diagnostic; this is the reading of the wasp test suite (3 + id 3+3 β†’ 9, 1+2 + square 3+4 β†’ 52) and the only one that keeps f 3-1 the same in every position (alternatives: argument is one atom (Haskell, the old operand behavior, contradicts statement level); diagnostic for any operator after a braceless argument (rejects the solved f 3-1); whitespace-sensitive extent f it - 1 vs f it-1 (wiki/Bad.md warns against significant whitespace)). Not yet: multi-parameter functions at statement level keep the list form, add 3 4 > 5 passes the comparison as last argument.

Hidden side effects / "pure" functions that are not

Every mainstream language: a helper deep in the call tree prints, logs or phones home and nothing says so.
Warp: log(x) := puts x⏎square(x) := log(x) ! Pure⏎square(3) β†’ Error('effect violation at 2:1: square is declared ! Pure but performs IO via square β†’ log β†’ puts'). Effects are inferred along the call chain and a module without IO calls has no WASI import at all, so it cannot do IO (DESIGN.md β†’ Effects as enforced capabilities). (test_hidden_side_effect_is_rejected, tests/test_effects.rs)

Integer overflow

C, Java, Go, Rust --release: INT64_MAX + 1 β†’ INT64_MIN; x*x for x = 3037000500 β†’ -9223372036709301616; Java: Math.abs(Long.MIN_VALUE) β†’ negative
Warp (since fcbd300b): Int is unbounded, an i64 fast path promotes to BigInt on overflow: square(x) := x*x; square(3037000500) β†’ 9223372037000250000, 2^64 β†’ 18446744073709551616, abs(-2^63) β†’ 9223372036854775808, 2^100 β†’ 1267650600228229401496703205376. Wrapping is an explicit opt-out: (2^63) as i64 β†’ -9223372036854775808 (🐞 as i64 inside a function body still panics, test_explicit_wrap_inside_function). law square(x) >= 0 now holds at runtime; law still turns a false property into a loud failure: dec(x) := x-1⏎law dec(x) >= 0⏎dec(0) β†’ Error('law (dec x)>=0 violated: counterexample x=0') (DESIGN.md β†’ Progressive verification). (test_integer_overflow_does_not_wrap, test_law_holds_because_integers_do_not_wrap, test_law_catches_a_false_property, tests/test_unbounded_int.rs)

Big literals

JS: 100000000000000000000 β†’ 1e+20 (a double); Warp: 100000000000000000000 β†’ 100000000000000000000, round-trips exactly.

Scientific notation and digit separators

Python/JS/Rust: 1e3 β†’ 1000.0, 1_000_000 β†’ 1000000
Warp before 9682281d: 1e3 β†’ the list 1 e3, 1_000_000 β†’ the list 1 _000_000, .1 β†’ parse error: silently parsed as something else. Warp: 1e3 β†’ 1000, an exact integer like the literal it abbreviates (1e20 == 100000000000000000000 β†’ true); 1.5e3 β†’ 1500, 2E-3 β†’ 0.002 (floats); 1_000_000 β†’ 1000000 (_ only between digits); .5+1 β†’ 1.5, -.5+1 β†’ 0.5, while [.1 .2] stays a two-element list. 2em, 1e, 1_ are not numbers. (test_scientific_notation, test_scientific_notation_forms, test_digit_separators, test_leading_dot_literal)

Proof model matches unbounded integers

Lean/Coq/Dafny exports over a mismatched numeric type can prove statements the runtime violates or reject statements it satisfies. Warp exports its unbounded Int as Lean Int: law square(x) >= 0 is both true at runtime for 3037000500 and proved universally. Explicit as i64 values still wrap and are not exported yet. Details in notes/laws.md. (test_proof_model_matches_unbounded_int, tests/test_law.rs)

Closures capturing loop variables

Python, JS var, Go < 1.22: [lambda: i for i in range(3)] β†’ all return 2 Warp: free variables are read at call time (Python's late binding, charged Β§3). A free variable that changes after the definition must be declared global in the reading function (or nonlocal in a nested def); otherwise the change is a compile error where a later call would see it: x=1;f(y):=x+y;x=5;f(1) β†’ error "f reads x … declare global x in f"; with global x=1;f(y):=x+y;x=5;f(1) β†’ 6. A free variable that never changes needs no declaration: x=1;f(y):=x+y;f(1) β†’ 2. Lists and texts work the same (xs=(1 2 3);f(i):=xs#i;f(2) β†’ 2); mutating them after the def also needs global. Loop variables need no declaration: a function created inside a loop captures that iteration's value, so the classic footgun does not apply: fs = []; for i in 0..3 { fs.add(x => x + i) }; fs#1(0), fs#2(0), fs#3(0) β†’ 0, 1, 2 (Python: 2, 2, 2); x=0;r=0;i=0;while i<3 { i+=1; x=i; f(y):=x+y; x=100; r+=f(0) }; r β†’ 6 (shared late binding without per-iteration: 300). (test_closures_capture_values, test_closures_in_loop_capture_each_iteration, tests/functions/test_late_binding.rs) Decision: late binding with an explicit global/nonlocal for any free variable that changes after the definition (charged Β§3, revising the earlier capture-by-value D7/#33); loop variables are captured per iteration so this footgun stays closed. Default arguments stay evaluated per call (see Mutable default arguments below). (alternatives: silent late binding like Python β€” the loop footgun returns; snapshotting free variables with no global path β€” hides dependency on later state; no capture at all.)

Mutable default arguments

Python: def f(a=[]): a.append(1); return a β†’ second call returns [1, 1]
Warp: a default is an expression evaluated at every call that omits the argument, so each call gets a fresh value: def f(a=(0 0)){ a#1 = a#1 + 1; a#1 }; f()+f() β†’ 2 (shared default: 3). A parameter takes the kind of its default (list, text, float), untyped parameters stay Int. (test_default_argument_is_fresh_per_call)
Decision: defaults are evaluated per call, at the call site (alternatives: evaluate once at definition, Python's choice, safe only once value semantics / copy-on-write lands).
Still open: a.add(1) on a list is a no-op today (a=();a.add(1);a panics, pixel.add(5) does not grow pixel), see the lists tests.

Date guessing in data

Excel: typing the gene name SEPT2 β†’ 2-Sep
Warp: SEPT2 β†’ symbol SEPT2; no date or unit guessing when reading data.

The Norway problem

YAML 1.1: country: NO β†’ country: false
Warp: warp data / parse_data("country: NO") β†’ country:NO; answer: yes β†’ answer:yes; [de gb no] stays three symbols. In data only JSON's words true, false, null (and glyphs like βœ”, βŠ₯, ΓΈ) are literals. (test_norway_problem_in_data)
Decision: code and data differ, as in wiki Todo.md "norway-problem" solution 1 (symbol vs expression context): in code yes/no stay the documented boolean aliases (warp 'country: NO' β†’ country:0, quote 'NO' there); in data (the parse-only path) every English word except true/false/null is a symbol, so aliases yes, no, on, none, pi never change foreign data. (alternatives: drop yes/no everywhere (breaks the documented alias, peq!("no",Empty)); case-sensitive aliases only (still turns lowercase no into false); a %w[de gb no]-style symbol list syntax.)

Numbers that are not numbers

YAML, Excel, CSV importers: version: 1.10 β†’ 1.1, zip: 01234 β†’ 1234
Warp: parse_data("version: 1.10").serialize() β†’ version:1.10, zip: 01234 β†’ zip:01234, 0xFF and 1_000 too: a number whose value prints differently keeps its source text in Meta (Node::source_literal), and serialization prints it. The value is still the number 1234. (test_data_keeps_number_literals)
Decision: the literal stays a number with its spelling kept in Meta (DESIGN.md: metadata rides along, the value stays exact); code keeps evaluating 010 β†’ 10 (Octal literals above). (alternatives: leading-zero / trailing-zero literals become text unless a numeric type is expected (breaks 010 β†’ 10 and makes the type depend on spelling); require quoting.)

Data that executes

Early JS eval(json), YAML !!python/object, pickle: loading data runs code
Warp: warp data <file> and warp::parse_data read .wasp data without evaluating it (secret = fetch … comes back as the text of the call). Evaluating foreign data goes through wasm_emitter::eval_untrusted, which runs with an empty capability set: a program resolving any host, WASI or FFI call (puts, fetch, use m;floor) is an error before it is compiled; pure code (x:=3;x*x β†’ 9) runs. Plain eval still grants the capabilities the program's effects need (tests/test_effects.rs::test_imports_follow_effects). (test_data_does_not_execute)

Memory-safety classics: use-after-free, double free, dangling pointers

C, C++: free(p); p->x β†’ undefined behaviour
Warp: by construction. Nodes are WASM GC structs and the surface language has no pointers, no free, no pointer arithmetic; the WASM sandbox traps any access outside linear memory, and indexing is bounds checked (next entry).

Index out of range / negative index

C: a[3] on 3 elements β†’ undefined behaviour; JS: undefined; Python: a[-1] wraps silently
Warp before: x=[1 2 3]; x[3] β†’ the unevaluated program text x=[1 2 3]; x#4; x#0 β†’ 1, x[-1] β†’ 1, "ab"#5 β†’ ''.
Warp: x=[1 2 3]; x[3], x#0, x[-1], "ab"#3 and x#4=0 β†’ Error('index out of range'). Every list and text index is checked at runtime; the trap becomes an error value instead of the program text. Not yet: the error carries no source span, and #-1 (last element, only if spelled so) is not implemented, so it is an error too. (test_index_out_of_bounds_is_an_error, test_negative_index_is_an_error)

Parsing numbers from text

C atoi, PHP: atoi("12a") β†’ 12, (int)"abc" β†’ 0
Warp before: int("12a") β†’ 0, float("1.5x") β†’ 0.0, int("123456789012345678901234567890") β†’ a truncated double.
Warp: int("12a"), float("1.5x"), int("") β†’ Error('invalid number'); only a text that is one whole number literal converts (int(" -12 ") β†’ -12, int("2.7") β†’ 2, big literals stay exact). Not yet: the error aborts the program instead of being a recoverable Result (DESIGN.md β†’ Effects), and runtime (non-literal) text is not converted at all. (test_invalid_number_text_is_an_error)

Aliasing

Python, JS, Java: a=[1]; b=a; b[0]=9; a β†’ [9]
Warp before: a=(1 2);b=a;b#1=9;a#1 β†’ 9, and even strings: x="ab";y=x;y#1="z";x β†’ 'zb'; worse, equal literals share one string-table entry, so x="ab";y="ab";y#1="z";x β†’ 'zb' without any alias.
Warp: value semantics (DESIGN.md β†’ Ownership): y#i=v stores an updated copy in y (node_with_at: a list copies the cells up to i and shares the tail, a text gets fresh bytes), nothing is mutated in place, so all three examples leave x/a unchanged. Not yet: no uniqueness analysis, so every index assignment copies (O(i) for lists, O(length) for texts, runtime texts are bump-allocated and never freed). (test_mutation_through_alias_is_not_visible, test_equal_literals_are_not_shared)

String + number

JavaScript: "5" + 3 β†’ "53", "5" * 3 β†’ 15
Warp before: "5"+3 β†’ 56, "5"*3 β†’ 159, "a"+1 β†’ 98, 3 + "4" β†’ 55 (one-character strings became code points, C's '5' + 3), "ab"+3 β†’ compiler panic.
Warp: all of them β†’ Error('type error: codepoint + int: no implicit conversion, convert explicitly, e.g. int("5") + 3') (DESIGN.md β†’ Dangerous implicitness); int("5") + 3 β†’ 8. Arithmetic on a text, character or list operand is a compile-time type error (arithmetic_kind β†’ Kind::Error), reported before the module runs. Not yet: no source span. Two texts concatenate: "a" + "b" is "ab". (test_text_plus_number_is_a_type_error)

Lists and arithmetic

Python: [1,2,3]*2 β†’ [1,2,3,1,2,3]; NumPy: [2,4,6]; JS: [1,2]+[3] β†’ "1,23"
Warp before: [1 2]+[3] β†’ 5, [1 2 3]*2 β†’ 6 (the list was evaluated as a statement sequence: its last item).
Warp: [1 2]+[3] β†’ [1 2 3] (list_concat copies the left cells and shares the right list, a+b leaves a unchanged); [1 2 3]*2 β†’ Error('type error: list * int: lists only concatenate with lists (+), element-wise arithmetic needs an explicit map'), element-wise lifting only through a law-governed rule (wiki/broadcasting.md). [1 2 3]+4 asks (user, 2026-10-03): append or add to each element? Unanswered it is an error naming both explicit forms, [1 2 3] + [4] (concatenates) and [1 2 3] .+ 4 (element-wise; also .- .* ./). ./ glued to what follows, not to an operand before it, is a file path (card play-mozart): play ./mozart.mp3, xs ./2 is the list [xs "./2"]; divide each with a space, xs ./ 2, or glued to the operand, xs./2. A list variable plus a number (pixel + 4, the ignored test_array_operations) stays a type error. 1 -1 (a space before the sign, none after) asks list or arithmetic; unanswered it is the list [1 -1] with a warning, the subtraction is written 1 - 1. Only after a number literal: x -1 subtracts. (test_list_plus_concatenates, test_list_plus_number, test_signed_operand_list)

Compound assignment to an element

Warp before: a=(1 2);a#1 += 1;a#1 β†’ compiler panic Expected symbol in compound assignment.
Warp: 2; x#i op= v is x#i = x#i op v with the same value semantics as x#i = v. (test_compound_index_assignment)

const ignored

Warp before: const x=5;x=6;x β†’ 6. Warp: Error('x is const, cannot assign it again: x=6 at 1:11; fix: use a new name instead of x, or declare it without const'), also for x+=1, x++ and element assignment a#1=3; the check runs before emission (check_constants), then const x=v lowers to x=v. Not yet: ::= does not parse (card constant-define-operator), and a const is not scoped (a function parameter of the same name is not reassigned, so no false positives, but no shadowing rules either). (test_const_is_single_assignment)

Methods that should update a list

Warp before: pixel=(1 2);pixel.add(5);pixel β†’ (1 2) (the call was evaluated and dropped), a=();a.add(1);a β†’ compiler panic Cannot extract numeric value from ΓΈ, [4]#1 β†’ trap (a one-element […] was emitted as its element). Warp: x.add(v) (also append, push) on a variable is sugar for x = x + [v], so pixel β†’ (1 2 5) and an alias keeps its value; [4] stays a list, and a=();a.add(1) gives [1]. (test_append_method_rebinds_the_list)

Data races

C, C++, Go, Java: two threads incrementing a shared counter β†’ lost updates
Warp: tasks (go f(x), go { … }) run on real threads, but each in its own instance with copies of its inputs (thread, P33): two tasks never write the same variable. What tasks share must be declared shared (a value, shared n = 0, P106, or an array, shared xs = int[8], P44), and every write to it is atomic in the host, so n += 1 from two tasks never loses an update (test_shared_values). Parallelism beyond that is only inferred for code proved pure (DESIGN.md β†’ Cautions).

String comparison

Java: new String("abc") == "abc" β†’ false; Python: a is b works for 256 but not 257; JS: [1,2]==[1,2] β†’ false
Warp before: "abc"=="abc" β†’ compiler panic Cannot extract numeric value from 'abc'; "abc" is "abc" unevaluated; 0=="" and null==false panicked.
Warp: "abc"=="abc" β†’ 1, x="abc";x=="abc" β†’ 1, "abc" is "abc" β†’ 1, x=257;x is 257 β†’ 1, [1 2]==[1 2] β†’ 1, 0=="" β†’ 0, null==false β†’ 0, 3==3.0 β†’ 1. ==, != and is compare by value (wiki/equality.md): kinds must be compatible (Int and Float are), texts byte by byte, lists and keys recursively; there is no identity operator. (test_string_equality_is_by_value, test_is_compares_by_value, test_equality_across_kinds_is_structural)

Unicode normalization

Almost every language: "Γ©" == "Γ©" (NFC U+00E9 vs NFD e+U+0301) β†’ false
Warp before: NFC vs NFD β†’ compiler panic.
Warp: '\u{e9}'=='e\u{301}' β†’ 1. Source text is normalized to NFC when parsed, so equal-looking texts and identifiers are equal. (test_unicode_normalization)

Duplicate keys

JSON (RFC 8259 leaves it open), JS, Python json: {"a":1,"a":2} β†’ silently {"a":2}
Warp before: {a:1 a:2} accepted without complaint.
Warp: {a:1 a:2} β†’ Error('duplicate key 'a'') at parse time. Code blocks without braces may still redefine (global x=5; global x=10; x β†’ 10). Not yet: the error has no source span. (test_duplicate_keys_are_reported)

Character indexing

C, Go: "hΓ©llo"[1] β†’ a byte, half of Γ©
Warp before: 'hΓ©llo'#2 β†’ 'Γƒ'.
Warp: 'hΓ©llo'#2 β†’ 'Γ©', 'aπŸ‘c'#3 β†’ 'c', 'hΓ©llo'#6 β†’ Error('index out of range'). # on text decodes UTF-8 and counts user-perceived characters (see Bytes vs graphemes below). (test_character_indexing_is_unicode_safe)

Bytes vs graphemes

Python 2, C, Go, JS: len("πŸ‘πŸ½") β†’ 8 bytes (Go), 4 UTF-16 units (JS), 2 codepoints (Python 3); users mean 1
Warp before: size "πŸ‘πŸ½" β†’ 8 (by accident: one node Γ— 8), #x of a text β†’ 1 whatever the text, 'hΓ©llo'.length not evaluated, 'πŸ‘πŸ½'#1 β†’ the thumb without its skin tone modifier.
Warp: 'πŸ‘πŸ½'#1 β†’ "πŸ‘πŸ½", x='aπŸ‘πŸ½b';x#3 β†’ 'b', #x, count x, x.length of "πŸ‘πŸ½" β†’ 1, count "πŸ‡©πŸ‡ͺπŸ‡«πŸ‡·" β†’ 2, "q\u{301}".length β†’ 1, size "πŸ‘πŸ½" β†’ 1. Explicit units: x.bytes β†’ 8, byte count of x β†’ 8, x.codepoints β†’ 2, x.graphemes β†’ 1; x.chars is the list of characters, with or without () (x.chars.count β†’ 2). A grapheme of one code point is a Codepoint ('hΓ©llo'#2 β†’ 'Γ©'), a longer cluster is Text sharing the bytes. Segmentation is UAX #29 abridged (combining marks of common scripts, variation selectors, skin tone modifiers, tags, ZWJ emoji sequences, flag pairs, CR LF): one table, strings::GRAPHEME_EXTEND, drives both grapheme_clusters in Rust and the emitted grapheme_end runtime, no new crate. (test_text_is_indexed_by_grapheme, test_text_units_are_explicit)
Decision: #, count, length and size count graphemes; bytes only under their name, x.bytes / byte count of x / byte_size(x) (user, P40: "size = count"; alternatives: (a) everything by code point, like Python 3 and Warp before, wrong for πŸ‘πŸ½ and flags; (b) size counts bytes, as wiki/string.md first said; (c) no default unit, # on text a type error until the unit is named, like Swift's views). Reasons: wiki/string.md specifies "size defaults to byte length, length defaults to codepoint (todo: graphemes)", wiki/char.md asks for count graphemes and wiki/ABI.md calls "UTF-8 + iterator of grapheme clusters" ideal; Footguns' intent "# by grapheme" keeps x#(#x) the last visible character. Memory is byte_size (lists: 8 bytes per element, byte_size(pixels) β†’ 24, size(pixels) β†’ 3). Not yet: [] is still # shifted by one, not the byte access wiki/indexing.md describes; typed iteration (for byte in text) and the wiki's count bytes of x phrasing; Hangul jamo, Indic conjuncts and Prepend marks beyond the table.

Index assignment of a multi-byte character

Warp before: x="ab";x#1='Γ©';x wrote one byte, 0xE9, an invalid UTF-8 text.
Warp: x="ab";x#1='Γ©';x β†’ "Γ©b", x="hΓ©llo";x#2='e';x β†’ "hello", x="aπŸ‘πŸ½c";x#2='b';x β†’ "abc" (the whole grapheme is replaced), x="ab";x#2='€';x.bytes β†’ 4. text_with_char_at encodes the code point as UTF-8 (1–4 bytes) into the copy it already allocates; values beyond U+10FFFF are invalid_number. Not yet: assigning a multi-code-point grapheme (x#1='πŸ‘πŸ½'), since the assigned value is lowered to one code point. (test_index_assignment_of_multi_byte_character)

Type annotations not enforced loudly

Python (hints are not checked), TypeScript (as any): x: int = 5; x = "five" β†’ accepted
Warp before: x:int=5;x="five";x β†’ compiler panic Cannot extract numeric value from 'five'; x:int="five" β†’ 0; x:float=5;x β†’ 0.
Warp: x:int=5;x="five";x β†’ Error('type mismatch: x is declared int, cannot assign text 'five' at 1:9; fix: x=int('five') or declare x:text'); x:int=5⏎x=2.5 is rejected (no silent lossy conversion), x:float=5;x β†’ 5.0 (widening), x:int=42;type(x) β†’ int. Declarations are checked before emission (analyzer::diagnose) and lowered to a typed local (lower_declarations). Not yet: only literal values are checked against the declaration; const (see NOT YET). (test_type_annotation_is_enforced)

Empty values

Python/JS disagree: [] is falsy in Python, truthy in JS; if ("") / if ({}) differ too; C: if (0.5) is true, but a truncating cast makes it false
Warp before: if "" {1} else {2} and if [] {1} else {2} β†’ compiler panic; if 0.5 {1} else {2} β†’ 2, if 2^32 {1} else {2} β†’ 2 (condition truncated to i32).
Warp: if "" …, if [] …, if ΓΈ …, x="";if x … β†’ else branch; if "0" …, if 0.5 …, if 2^32 … β†’ then branch. One rule (wiki/truthiness.md): false, 0, 0.0, ΓΈ and empty text/lists are falsy, everything else is truthy; it is defined once (Node::is_falsy, the is_truthy runtime) and used by if, while, ?:, and, or. (test_empty_condition_does_not_panic, test_empty_values_are_falsy)
Decision: keep "empty is falsy", uniformly (alternatives: (b) only bool is a condition, if "" a type error with fix-it if not empty "" (Rust, Swift, Go); (c) empty is truthy except ΓΈ/false (Ruby, Lua)). Reasons: the wiki specifies it and wiki/null.md builds optional narrowing on if x {…}; the Solved entry "0" is falsy pins if "0" β†’ then branch, which (b) would turn into an error; DESIGN.md's "arbitrary truthiness" objection targets per-type emitter heuristics, which one rule defined in one place is not. The ambiguous a and b or c idiom is linted instead (next entry). Revisit with (b) if a bool kind is introduced (it now is, as a shallow type: Booleans count as 1/0).

and/or as ternary

Python, Lua: cond and x or y β†’ y when x is falsy
Warp: 1 and 0 or 2 β†’ 2 (well defined, as in wiki/truthiness.md), but it prints the warning warning: `1 and 0 or 2` yields 2 whenever 0 is falsy at 1:1; fix: if 1 then 0 else 2; if 1 then 0 else 2 β†’ 0. analyzer::lint returns the warnings as Diagnostics. (test_and_or_ternary_is_linted)

Null

Java, C#, JS: obj.field on null β†’ NullPointerException / TypeError at runtime
Warp before: x=ΓΈ; x+1 β†’ compiler panic.
Warp: x=ΓΈ; x+1 β†’ Error('x may be ΓΈ (null) in x+1 at 1:6; fix: if x { x+1 }'), likewise x=ΓΈ; x.size. A variable assigned ΓΈ may not be used in arithmetic or member access until checked: if x {x+1} else {2} is accepted (flow-sensitive narrowing, wiki/null.md), as is reassignment x=ΓΈ; x=3; x+1; such programs run (next entry). (test_null_needs_a_check)

Null: optional types

Java, C, Go: every reference may be null, the type does not say so; Swift, Kotlin: Int? (solved there)
Warp before: x:int?=ΓΈ β†’ Unexpected character '='; x=ΓΈ; if x {x+1} else {2} β†’ compiler panic Cannot extract numeric value from ΓΈ.
Warp: x=ΓΈ; if x {x+1} else {2} β†’ 2, x=ΓΈ; x=3; x+1 β†’ 4, x:int?=ΓΈ; if x {x+1} else {2} β†’ 2, x:int?=ΓΈ; x=4; x+1 β†’ 5, x:int?=ΓΈ; x+1 β†’ Error('x may be ΓΈ …'), x:int=ΓΈ β†’ Error('type mismatch: x is declared int, cannot assign ΓΈ at 1:1; fix: declare x:int? to allow ΓΈ'). A variable declared T? or first bound to ΓΈ is held as a Node (ΓΈ until assigned) and read as a number only where the null check allows it. () is ΓΈ and ΓΈ is the empty list: a=();a.add(1) β†’ [1], a=();a.add(1);a.add(2);a β†’ [1 2]. (test_optional_local_runs, test_optional_type_declaration, test_empty_parens_is_the_empty_list)
Decision: () stays ΓΈ, which doubles as the empty list for list operations (append, + with a list) (alternatives: (b) () a distinct empty-list value β‰  ΓΈ; (c) () a type error outside calls). Reasons: wiki/SPECIFICATION.md "normally () is just the empty object ΓΈ", wiki/bool.md {} == () == ΓΈ, wiki/truthiness.md ()==false; parse("()") == ΓΈ is pinned by tests/test_parser.rs; (b) would make () and ΓΈ two empty values with different behaviour, which the wiki rejects ("one keyword always captures all destitute cases", wiki/null.md). Other member access on ΓΈ (x.size) still needs a check. Decision: T? is written as a suffix on the type name (x:int?), as in wiki/null.md and Swift/Kotlin (alternatives: ?x:int prefix, maybe int keyword; wiki/optional.md lists both as sketches); a plain T refuses ΓΈ. x! unwraps and optional floats compute (x:float?=0.5; x+1 is 1.5). Not yet: typed null Person.null (card typed-null).

Swallowed errors

Go: v, _ := f(); Java: catch (Exception e) {}; JS: unhandled promise rejection β†’ silent
Warp before: a value that has no number (1+(a:2), 2*class P{a:int}) β†’ compiler panic Cannot extract numeric value …; a link or instantiation failure returned the unevaluated parsed program as if it were the result.
Warp: law, effect, declared-type and null violations, invalid number text and index errors come back as Error values; 1+(a:2) β†’ Error('cannot extract a numeric value from a:2 at 1:4'), with the source position; a failed link or instantiation β†’ Error('could not run the program: …'), never the parsed program. x=0.5;x=0 β†’ 0 (was a WASM validation error). (test_compiler_failures_are_error_values)
Not yet: runtime traps carry no source span (wasm code offsets are not mapped back to source yet), Result<T, E> as data (DESIGN.md β†’ The compiler is a query interface).

SQL and shell injection

Every language with string building: "SELECT * FROM t WHERE name='" + name + "'" with name = "x' OR '1'='1" β†’ all rows
Warp before: no SQL or shell API; building a query was plain text concatenation.
Warp: name="x' OR '1'='1";q=sql "SELECT * FROM t WHERE name = $name" β†’ q#1 is SELECT * FROM t WHERE name = ? and q#2 is the value x' OR '1'='1: the literal is the query, every $name / ${expr} hole is a parameter, so no value can change the query's shape. sh "rm -f $file" is an argument vector: with file="a; rm -rf ~" the hole is one argument (c#3), nothing parses it as shell. Misuse is a diagnostic with position and fix-it: sql("…'" + name + "'") β†’ sql takes a literal template, a hole inside SQL quotes ('$name') β†’ a parameter is a value, sh "ls | grep x" β†’ no shell, sh "cp --out=$f" β†’ a sh hole must be a whole argument, execute q of plain text β†’ execute takes a sql template. Running is a capability: execute (sql) and exec (process) are IO externals, so effects of shows them and ! Pure rejects them along the call chain, and eval grants neither: execute sql "SELECT 1" β†’ capability denied: execute needs the sql capability, which eval does not grant (DESIGN.md β†’ Effects as enforced capabilities). Decision: tag-prefixed templates sql "…" / sh "…" with holes as parameters, ? placeholders, shell commands as argv without a shell, runners gated by the sql / process capability (alternatives: an escaping function like quote(name) (opt-in, forgotten once is enough, PHP's mysql_real_escape_string); typing every text with its language (Text<Sql>, heavier, and concatenation would still need rules); JS tagged templates with a user tag function (flexible, but the tag sees the pieces at runtime, so the capability cannot be checked statically); running sh through /bin/sh -c with quoted holes (keeps pipes, but one quoting bug is an injection)). Not yet: no host grants sql or process, so nothing actually runs a query or program; the template's language is tracked by the lowering pass (src/text/injection.rs) per variable, not by the type system; pipes and redirection have no typed form. (test_sql_holes_are_parameters_not_text, test_sql_from_built_text_is_rejected, test_shell_holes_are_whole_arguments, test_running_queries_and_commands_needs_a_capability)

Dates and time zones

JS: new Date(2024, 1, 31) β†’ March 2; Java: new Date().getYear() β†’ 124; everyone: local time jumps at DST
Warp before: no date/time type; 2024-01-31 parsed as the subtraction 2024-1-31 β†’ 1992.
Intended: distinct instant, date, local time, zoned time; no implicit current zone; months are 1-based; arithmetic on calendar units is explicit about overflow (Jan 31 + 1 month).

Decision: four distinct types, modelled on JS Temporal / java.time and TOML's literal rule (Solved elsewhere β†’ Date guessing): date (2024-01-31), local time (2024-01-31T10:00, date + wall clock, no zone), instant (2024-01-31T09:00Z or …T10:00+01:00, a point on the UTC line) and zoned time (2024-01-31T10:00[Europe/Berlin], RFC 9557). Literals are the RFC 3339 / RFC 9557 forms only (4-digit year, 2-digit month and day, no spaces), so 2024-01-31 is a date while 2024 - 1 - 31 stays arithmetic and SEPT2 stays a symbol; date(y,m,d) is the constructor. Months are 1-based (2024-02-29.month β†’ 2); invalid fields are errors, never rolled over (date(2024,2,30) β†’ error, JS: March 1). One implicit zone, the environment's: now is an instant; its year/hour are the wall clock of the time zone the program runs in (TZ, the system's, the browser's; user, 2026-10-10), any other zone is spelled out (t in "Europe/Berlin" β†’ zoned time). Types never convert implicitly: date < local time and local time < instant are errors. Calendar arithmetic rejects overflow by default: 2024-01-31 + 1 month β†’ error naming 2024-02-31; clamping is spelled out, add(2024-01-31, 1 month, overflow: clamp) β†’ 2024-02-29 (Temporal's overflow: "constrain"). A nonexistent wall time in a DST gap is an error for literals, never a silent shift; 1 day on a zoned time is a calendar day, 24 hours is exact. date - date β†’ days as Int. (alternatives: Temporal's default constrain for + (hides the footgun behind a quiet clamp), JS/java.util.Date rollover (the footgun itself), a single timestamp type plus zone field (Python naive/aware datetime: mixing them fails only at runtime), constructor-only dates with no literal (safe but verbose; the RFC 3339 form is already unambiguous data, as in TOML).) Warp: implemented as decided. 2024-02-29.month β†’ 2, date(2024,0,1) β†’ Error('month out of range: 0 …'), date(2024,2,30) β†’ Error('day out of range: 2024-02-30'), 2024-01-31 + 1 month β†’ Error('2024-02-31 does not exist: use add(…, overflow: clamp) …'), add(2024-01-31, 1 month, overflow: clamp) == 2024-02-29 β†’ true (Int 1, as all eval booleans), 2024-03-01 - 2024-02-01 β†’ 29, now.hour β†’ the hour where the program runs, (2024-03-31T01:30Z in "Europe/Berlin").hour β†’ 3, 2024-03-31T02:30[Europe/Berlin] β†’ Error('… does not exist in Europe/Berlin …'), date < local time and local time < instant β†’ errors naming both kinds, 2024-01-31T10:00+01:00 == 2024-01-31T09:00Z β†’ true; 2024-1-31 is still 1992. How: parse_number lexes the strict RFC 3339 / RFC 9557 form into a TimeLiteral data node (validated on evaluation, like date(…)), and an integer followed by a unit word (year month week day hour minute second, singular or plural) into a Duration (months, days, exact nanoseconds). src/time.rs evaluates what it can at compile time and folds the constant time expressions of a program that runs (time::lower); the calendar core is src/time/calendar.rs. now reads the clock when the program runs; dates, local times and instants are run-time values (prints, loops, variables, fields, comparisons). Planned (card run-time-dates): durations, arithmetic and zones at run time. Also not yet: the tz database is an embedded table of ~40 zones with today's EU/US rules only (no historical rules, no southern-hemisphere DST): unknown zones are errors, the full IANA database via a host import is future work. (test_months_are_one_based, test_calendar_overflow_is_explicit, test_no_implicit_time_zone, test_date_and_time_types_are_distinct, test_date_literal_needs_strict_form)

Exact results at the Node boundary

7/2+2 returns the exact Number::Quotient(11, 2) (prints 5.5), 3.14+2 the exact 5.14 (decimals are exact), 3.14f+2 the float 5.14. Decision (user, 2026-09-28): exact results stay exact at the API.

Variance

Java: Object[] a = new String[1]; a[0] = 1; β†’ ArrayStoreException at runtime
Warp today: no generic/subtyping rules exist to be unsound. Lists are heterogeneous (every element is a Node), so a=("x" "y");a#1=1 has no static element type to violate; the Java scenario cannot even be written, there is no String[] to upcast to Object[]. a#1 β†’ 1, verified (test_no_array_store_exception). The inferred-invariance rule below applies once typed collections exist.
Decision: variance is inferred, never annotated: an immutable collection (no mutation reachable through it, same analysis as ownership/effects) is covariant, [Text] may be used as [Any]; a collection that is mutated through the widened view is invariant, so a:[Text]=…; f(b:[Any]) := b#1=1; f(a) must be a type error, not a runtime trap. Value semantics (copy-on-write, see Aliasing) makes the widened copy a new list, which is the other sound way out. (alternatives: Java/C# covariant arrays with a runtime store check; Kotlin/Scala declaration-site out/in/+T/-T annotations; Java/C# use-site wildcards ? extends T; everything invariant like Rust/Go generics.) Why: DESIGN.md prefers inference with optional constraints over mandatory annotations and requires unsound or lossy operations to be explicit; inferred mutability is the same fact the ownership and effect analyses already compute.

Rounding mode

Python 3, .NET round(2.5) β†’ 2 surprises users of JS/Excel (3) and vice versa.
Warp before: round(2.5) β†’ 2 (banker's rounding), with no name for the rule and no alternative.
Warp: round is round half even (IEEE 754 default, Python 3, .NET): round(2.5) β†’ 2, round(3.5) β†’ 4; round_half_even names it; round_half_up(2.5) β†’ 3, round_half_up(-2.5) β†’ -2 (ties toward +∞, like JS Math.round), round_half_up(5/2) β†’ 3.
Decision: round stays half even, round_half_up and round_half_even are explicit (alternatives: a mode argument round(x, half: up); switching round to half up like JS/Excel, a silent change). Not yet: rounding goes through f64 (exact rationals could round exactly); no half-away-from-zero (Excel for negatives). (test_rounding_mode_is_named)

Negative modulo

C, JS, Java, Rust: -7 % 3 β†’ -1 (truncated, sign of the dividend), Python: 2, but 7 % -3 β†’ -2 (floored, sign of the divisor): each camp surprises the other.
Warp before: only %, the truncating remainder: -7 % 3 β†’ -1; then mod was added as the floored modulo.
Warp: % follows mathematics, Euclidean division a == b*q + r with 0 ≀ r < |b|, so the remainder is never negative: -7 % 3 β†’ 2, 7 % -3 β†’ 1, -7 % -3 β†’ 2, 7 % 3 β†’ 1, -123456789012345678901234567890 % 1000 β†’ 110, -7/2 % 3 β†’ 5/2. mod is the same operator (-7 mod 3 β†’ 2); rem is the truncated remainder under its own name (-7 rem 3 β†’ -1, 7 rem -3 β†’ 1). x %= y is Euclidean and an integer x /= y is the matching quotient (x - x % y) / y (x=-7; x /= 3 β†’ -3). Lean exports % as Int.emod (Euclidean) and rem as Int.tmod. A % with a negative literal or negated operand is linted: "-7 % 3 is 2: % is Euclidean as in mathematics; C/Java/JS give -1; use rem for the truncated remainder".
Decision (maintainer, 2026-09-28): % is Euclidean as in mathematics. Alternatives: truncated like C/Java/JS/Rust (-7 % 3 β†’ -1, the previous behavior), floored like Python/Haskell mod (7 % -3 β†’ -2). (test_modulo_and_remainder_are_both_named, test_negative_modulo_is_linted, test_law_lean_export_modulo_is_euclidean)

Booleans count as 1/0

Python, C, JS: True + True β†’ 2
Warp: true + true β†’ 2, false * 3 β†’ 0, (1<2) + 1 β†’ 2: in arithmetic a bool counts as 1 or 0, a literal like a variable (P195). bool is a shallow type of its own: type(yes) β†’ bool, and a bool prints as yes/no (3 > 2 β†’ yes); true/false stay accepted as input. == stays loose, === compares the type too (P196): 0 == false β†’ yes, 0 === false β†’ no.
Decision (user, card bool-type): "Yes is 1, no is 0", with bool as its own type; the earlier analyzer error "arithmetic on a boolean" and the Int 1/0 encoding are gone. (test_booleans_count_as_numbers, test_bool_is_a_type_of_its_own, test_strict_equality_compares_the_type)

NOT YET

... Currently all resolved, Except for these that are "impossible" and might be solvable

Footguns Warp still has (verified with the probes above: the Warp answer shown is today's output), or where the fix is designed but not implemented. Each entry names the intended resolution. Entries marked 🐞 are plain bugs, not design questions.

Events, signals and channels (not yet)

Removing a listener that does not exist

Warp (solved): remove h from listeners of tick (or remove h from tick) where h is no handler of tick is the compile error "h is no named listener of tick". listeners of tick is the list of its handlers' places, so any handler, also an unnamed one, is removed by its place (remove 2 from listeners of tick); a removed place does nothing.

"The" length of a string

There is no single right answer (bytes, UTF-16 units, codepoints, grapheme clusters), segmentation changes with Unicode versions, and case mapping is locale dependent (Turkish i ↔ Δ°).
Warp: make the unit explicit (byte, char, grapheme) instead of choosing silently. e.g. #(byte in text) e.g. #(text as bytes) e.g. number of chars in text e.g. number of graphemes in text "πŸ‘πŸ½" is 8 bytes (Go len), 4 UTF-16 units (JS .length), 2 code points (Python len), 1 grapheme (Swift .count). Warp (solved): the unit is named when it matters. #t, count t, length t, size t, number of t count graphemes, the same unit t#i indexes by. Explicit units: number of bytes in t / #(byte in t) / #(t as bytes) / t.bytes; number of chars in t = number of codepoints in t = t.codepoints (t.chars is the list); number of graphemes in t = t.graphemes. number of chars in "hΓ©llo" β†’ 5, number of bytes in "hΓ©llo" β†’ 6, number of codepoints in "πŸ‘πŸ½" β†’ 2, number of graphemes in "πŸ‘πŸ½" β†’ 1. A char is a code point; a user-perceived character is a grapheme. Tests: probe_footguns.rs test_number_of_{chars,graphemes,bytes,codepoints}_in_text, test_count_of_text_without_unit. Case mapping: "i".toUpperCase() in a Turkish locale is "Δ°" in Java (default locale), breaking identifiers and keyword matching. Warp (decided): case mapping is locale-independent Unicode default mapping, as JS toUpperCase and Rust to_uppercase: "i".upper β†’ "I", "Δ±".upper β†’ "I", "Δ°".lower β†’ "iΜ‡" (i + combining dot), "straße".upper β†’ "STRASSE". Test: test_case_mapping_is_locale_independent. Open: locale-aware mapping (Turkish/Azeri, Lithuanian) and collation need a way to name a locale; not designed yet.

Exact real numbers

Richardson's theorem: equality of real expressions built from Ο€, exp, sin, … is undecidable, so √2 * √2 == 2 cannot hold for every real computation.
Warp: rationals are exact, algebraic numbers, Ο€, e etc could be kept symbolic! see Hyperreal numbers for pragmatic extensions of Q (Also needed for law proofs ) beyond that the result is an approximation and its type says so. Warp now (2026-09-28): √2*√2 == 2, sqrt(8) == 2*√2, βˆ›27 == 3, sin(Ο€/6) == 1/2, cos(Ο€) == -1, β„―^(β…ˆ*Ο€) == -1, ln(β„―) == 1, Ο€ > 3.14, Ο€ < 355/113; Ο€+β„―, √2+√3, Ο€/2, 2√2 print symbolically; sin(1) prints β‰ˆ0.8414709848078965; Ο€ as float is 3.141592653589793. A bare e or i stays a free name; the constants are β„―/euler and β…ˆ. Constant expressions are evaluated exactly at compile time; inside functions and loops exact reals are still f64 (next: WASM GC form). Tests: tests/probe_footguns.rs test_square_roots_multiply_exactly … test_euler_identity.
Decision: an exact real is a sparse polynomial with rational coefficients over named generators (Ο€, β„―, β…ˆ, √r, βˆ›r) in a normal form; Ξ΅ and Ο‰ join later as generators ordered by lowest Ξ΅ power.
Decision: Ο€ and β„― are treated as algebraically independent (Schanuel's conjecture, unproven), so equal normal forms ⇔ equal values.
Decision: == compares normal forms, </> use interval arithmetic up to 4096 bits; what cannot be decided is a loud Error('undecidable …'), never a guess (sin(1) == sin(1) is such an error).
Decision: a value that is only an approximation prints with β‰ˆ; as float/as fast converts an exact value to f64 explicitly.

Fast and lawful floats

IEEE 754: (0.1+0.2)+0.3 == 0.1+(0.2+0.3) β†’ false; hardware floats are not associative.
Under floats this stays false forever; the choice is exact numbers (slower) or floats whose laws are weaker. law makes the chosen trade-off checkable. Alleviation: Have simple, memorable names for both: float (f64) and real Alleviation: Pick either fast or lawful float fast x=3.3 or x=3.3 as float vs real x=3.3 x=3.3 as real exact x=3.3 x=3.3:exact x = 3.3 fast or real, which should be the default?? Decision (exact-default, 2026-10-10, superseding 2026-09-28's exact decimals): a decimal is a float; fractions of integers stay exact. Warp: (0.1+0.2)+0.3 == 0.1+(0.2+0.3) β†’ no, with the warning "use β‰ˆ"; (1/10+2/10)+3/10 == 1/10+(2/10+3/10) β†’ yes; exact x = 0.1 keeps a decimal exact. Names: exact (alias real, strictly β„š plus Ο€, β„― and roots, not all of ℝ) and float (aliases fast, f64, double). Spellings: exact x=3.3, x:exact=3.3, x=3.3 as exact; fast x=3.3, x:float=3.3, x=3.3 as float. as converts the whole expression to its left (2 * 1.5 as int β†’ 3, with a warning offering both readings); the tight form sits on the literal: 1.5:int, 0.1:float and the C/Java/C# suffixes 0.1f/0.1d (float), 0.1l (exact).

Guessing intent

if fib 3 = 5 (wiki/Bad.md): no parser can know whether = means assignment, definition or comparison.
Warp: never guess in the emitter; an ambiguity is resolved once (by the programmer or an agent) and the choice is persisted, keyed by content hash (DESIGN.md β†’ Content-addressed resolutions). So: Really? The guess is impossible, the footgun is not. Warp: every syntactic ambiguity found so far is a diagnostic listing all readings as fix-its, never a silent choice: 3 & 4 == 4 β†’ ambiguous: `&` mixed with a comparison … fix: 3 & (4 == 4) or (3 & 4) == 4; 1==1==1 β†’ ambiguous: equality does not chain … fix: 1==1 and 1 == 1 or (1==1) == 1; 2 * 1.5 as int β†’ warning converts the whole `2*1.5` … fix: (2*1.5) as int or 2 * 1.5:int; x=1;if x=2 {3} else {4} β†’ 4 (= compares in conditions); braceless calls take the whole argument consistently: 1 + f 3-1 β†’ 1 + f(3-1). Left open: persisting a resolution (content-addressed, DESIGN.md) is designed, not implemented.

Nondeterministic NaN bits

The WebAssembly spec allows NaN payload bits to differ between engines (and relaxed SIMD results to differ between CPUs).
Warp: canonicalize NaNs where determinism matters, at a cost; exact numbers avoid NaN altogether. Solved in Warp. Every engine the compiler creates canonicalizes NaNs (cranelift_nan_canonicalization), so a NaN produced by arithmetic is always 0x7ff8000000000000 on x86 and ARM and float results are bit-identical across CPUs. Test: test_nan_bits_are_canonical.

Remote calls that look local

Fallacies of distributed computing: the network is not reliable.
Warp: fetch carries the IO effect, so every caller sees it may fail or block; making it infallible is impossible. Warp: solved. fetch "http://127.0.0.1:9/" β†’ Error "fetch http://127.0.0.1:9/ failed: …"; HTTP 404 β†’ Error "HTTP status 404"; x = fetch …; x + "!" β†’ diagnostic "x may be an error (fetch can fail)", fix if x { x + "!" }; errors are falsy so if x {x} else {"offline"} β†’ "offline"; default timeout FETCH_TIMEOUT (10 s), fetch URL timeout 0.5 β†’ Error "timeout after 500 ms". Tests: test_failed_fetch_is_an_error_value, test_fetch_result_needs_a_check, test_fetch_times_out_loudly.

"Truely Impossible"

... Really?

Footguns no language can remove in general, because the limit is mathematical or physical. Warp's answer is to make the limit visible instead of pretending it is gone.

Deciding termination and exact behaviour

Halting problem, Rice's theorem: no compiler can tell for every program whether it terminates, which effects it really performs or what it costs.
Warp: effects are over-approximated (the union of everything reachable, tests/test_effects.rs), costs are declared in signatures and checked by benchmarks, never derived in general (DESIGN.md β†’ Cautions). Truly impossible to decide in general; Warp handles both sides without a proof assistant. Runtime: every run has a fuel budget (default 10^11 steps, WARP_FUEL=<steps> or warp --fuel <steps>); while 1 {} ends with Error('out of fuel after N steps: the program may not terminate …') instead of hanging. Compile time: the effect system has Koka's Div. Recursion that moves one parameter by a positive literal toward a guarded literal bound (fib(n) := n<2 ? n : fib(n-1)+fib(n-2)) is total; while, unguarded or unbounded recursion (f(n) := n==0 ? 1 : n*f(n-1) diverges for n<0) and mutual recursion are Div. f ! Pure rejects a function that may diverge, effects of f reports Div. Conservative: when unsure, Div. Deep recursion: the warp command gives a program a 512 MB stack, so f(n) := if n == 0 then 0 else 1 + f(n - 1) runs f(1000000) (a million frames) without overflowing. Tests: test_infinite_loop_runs_out_of_fuel, test_fuel_budget_can_be_raised, test_shrinking_recursion_is_total, test_unproven_recursion_may_diverge, test_while_loop_may_diverge, test_pure_rejects_divergence.

Function equality

f == g for arbitrary functions is undecidable.
Warp: laws state the properties that matter and are tested or proved per function.

  • Warp: f == g on named functions is decided by (1) structure up to renaming (content hash): f(x):=x+1; g(y):=y+1; f==g β†’ true; (2) polynomial normal form over exact numbers: f(x):=(x+1)^2; g(x):=x^2+2*x+1; f==g β†’ true, x*x vs x+x β†’ false; (3) enumeration of finite (bool) domains: De Morgan holds; different arity or parameter types β†’ false. (4) Otherwise an error undecidable: f == g …, with they differ: counterexample x=2 when a quick property test finds one (f(x):=x%2; g(x):=x%3). For more, state a law or prove it in Lean.
  • Tests: test_function_equality_up_to_renaming, test_polynomial_function_equality, test_finite_domain_function_equality, test_undecidable_function_equality_is_an_error (tests/probe_footguns.rs).

Future civil time

What UTC instant is 2030-03-31 02:30 Europe/Berlin? Time zone rules change by political decision after the code is written.
Warp: store instants for past events and zone + local time for future ones; no type can know tomorrow's politics. Python: datetime(2030,3,31,2,30,tzinfo=ZoneInfo("Europe/Berlin")) silently becomes 01:30 UTC; the repeated 02:30 in October silently takes fold=0. JavaScript Temporal: disambiguation: 'compatible' | 'earlier' | 'later' | 'reject'.
Warp: a zoned time is its wall time plus zone; the offset and instant are derived under a recorded rules version. 2030-03-31T02:30[Europe/Berlin] β†’ Error "does not exist … skipped by a daylight saving transition", listing 01:30+01:00 (earlier) and 03:30+02:00 (later); 2030-10-27T02:30[Europe/Berlin] β†’ Error "occurs twice", fix-its 2030-10-27T02:30+02:00[Europe/Berlin] (earlier) / +01:00 (later). The choice is spelled out: zoned(2030-10-27T02:30, "Europe/Berlin", disambiguation: later), add(t, 1 day, disambiguation: earlier); reject is the default. Future times print the rules they were resolved with, 2030-07-01T10:00+02:00[Europe/Berlin][_tzdata=warp-2026a] (t.tzdata); read back under rules where the offset changed, it is an Error naming both versions, with fix-its "keep the wall time" and "keep the instant". + 1 day keeps the wall time, + 24 hours the elapsed time (23 hours apart across the March switch), and 1 day == 24 hours is an Error, not false.
Still impossible: knowing tomorrow's politics. Warp only notices when the rules changed. Tests: test_repeated_local_time_needs_disambiguation, test_skipped_local_time_can_be_chosen_explicitly, test_zoned_time_records_its_rules_version, test_rule_change_is_not_silent, test_calendar_day_is_not_24_hours.

Proofs about a model

A proof is only as good as its model of the runtime: a Lean proof over β„€ says nothing about wrapping i64, and nothing can prove the compiler, the WASM engine and the hardware themselves correct from inside the program ("Reflections on Trusting Trust").
Warp: the Lean export models Warp's actual unbounded integers as Lean Int (see Solved); property tests and runtime assertions check the compiled program, not the model.

Timing side channels

No high-level language can guarantee constant-time execution on arbitrary hardware (Spectre, cache timing); WASM engines JIT differently.
Warp: out of scope, crypto belongs in audited host functions with the FFI effect.

⚠️ **GitHub.com Fallback** ⚠️