Skip to content

Footguns

pannous edited this page Oct 3, 2026 · 4 revisions

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 # 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) Todo 7⨸2 = 3 "round division" python 7∕∕2 7//2 vs comment? // comment only with space ! see #

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 before this change: 0.1+0.2==0.3 → 0, 1/3*3==1 → 0.
Warp: numbers are exact rationals by default (DESIGN.md → Exact numbers by default): 0.1+0.2==0.3 → 1, 0.1+0.2 → 0.3, 1/3*3==1 → 1, 1/3 → 1/3 (Number::Quotient), 2^-2 → 0.25. 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). A decimal literal with up to 15 significant digits is exact; f64 only comes from sqrt/FFI float functions, irrational constants (π) and as float, and an f64 operand makes the result an f64. x /= y keeps an integer x an integer (truncating), as int truncates a ratio. Still inexact: 3 == 3.0000000000000001 → 1, the literal is parsed to f64 before it becomes exact. Ratios whose parts exceed i64 come back from WASM as an f64 approximation (with a warning) because Quotient is (i64, i64). (test_exact_decimal_arithmetic, test_exact_rationals_stay_exact)

NaN and infinity

IEEE 754 everywhere: NaN == NaN → false, 1/0 → Infinity, sqrt(-1) → NaN, and NaN poisons all later math quietly.
Warp before this change: x=0.0/0.0; x==x → 0, 1/0 → inf (an f64).
Warp: exact division by zero gives the extended rationals ±∞ = ±1/0 and NaN = 0/0 (wiki/int60.md reserves ±∞ and NaN too): 1/0 → ∞, -1/0 → -∞, 1/0 > 10^100 → 1, 1/(1/0) → 0, 1/0 - 1/0 → NaN; NaN equals only itself: x=0/0; x==x → 1, 0/0 == 1 → 0. Truncating ∞ or NaN to an integer ((1/0) as int) traps. Left open: sqrt(-1) → NaN is still the f64 of the float path (√-1 could be i since Complex exists), and ordering against NaN is not total (0/0 < 1 → 0 but 0/0 > 1 → 1). (test_division_by_zero_is_extended_rational, tests/test_types.rs::test_auto_type_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)

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)

"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; the error is a panic, not yet a structured diagnostic)

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 | stay the logical and/or (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, & stays logical and (alternatives: & 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 stops at ranges, 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. 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: functions capture the outer variables they read by value, at the point of definition: x=1;f(y):=x+y;x=5;f(1) → 2; a function defined in a loop sees that iteration's value: x=0;r=0;i=0;while i<3 { i+=1; x=i; f(y):=x+y; x=100; r+=f(0) }; r → 6 (by reference: 300). Captured lists and texts work too: xs=(1 2 3);f(i):=xs#i;f(2) → 2. (test_closures_capture_values, test_closures_in_loop_capture_each_iteration)
Decision: capture by value at definition time, matching DESIGN.md "immutable local bindings"; a name bound to a function is its latest definition (functions are not yet first-class values). (alternatives: capture by reference, as wiki/assignment.md sketches for z := y*y re-evaluating with the current y, which reintroduces this footgun; or no capture at all, the old behaviour.) Implementation: each captured variable gets a WASM global per function, set where the function is defined.

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; text concatenation ("a" + "b") is not implemented, so it is a type error too. (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 .- .* ./). 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 (Unexpected character '='), 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. Not yet: () parses as ø, so a=();a.add(1) is now the null-use diagnostic a may be ø (null) in a.(add 1) instead of a panic; making () the empty list is the null/truthiness owners' call. (test_append_method_rebinds_the_list)

Data races

C, C++, Go, Java: two threads incrementing a shared counter → lost updates
Warp: currently vacuous: generated modules are single threaded and share no memory. The plan keeps it that way: parallelism 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 "👍🏽" → 8. Explicit units: x.bytes → 8, x.chars → 2 (code points), x.graphemes → 1. 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 and length count graphemes, size counts bytes (alternatives: (a) everything by code point, like Python 3 and Warp before, wrong for 👍🏽 and flags; (b) size by grapheme too, making all three synonyms as wiki/length.md says; (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. size stays memory (lists: 8 bytes per element, size(pixels) → 24). 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 (NOT YET → Booleans are integers).

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 ø. Not yet: x! unwrap, typed null (Person.null), optional floats read back through the Int path (x:float?=0.5; x+1).

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: Undefined variable is still a panic (pinned by test_undefined_variable_is_an_error), 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/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). No implicit zone: now is an instant, which has no year/hour until placed in a zone (t in "Europe/Berlin" → zoned time); nothing reads the host's zone. 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 → Error('instant has no hour …'), (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 programs that use them at compile time (time::answer, before emission); the calendar core is src/time/calendar.rs (no dependencies). Not yet: dates have no WASM GC runtime representation, so a date program is folded at compile time and anything the folder does not know (functions, loops, lists of dates) is an error saying so; now reads the compiler's clock (needs a host clock import and a clock effect); 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

Since exact rationals, 3.14+2 returns the exact Number::Quotient(257, 50) (prints 5.14), not Number::Float. Decision (user, 2026-09-28): results stay exact at the API; tests/test_type_upgrading.rs's helper also accepts Quotient and compares it as f64 (8c8a18bb). (alternatives: convert non-integer results to f64 when leaving WASM, silently dropping exactness; decimal literals stay f64, giving up 0.1+0.2==0.3.)

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 are integers

Python, C, JS: True + True → 2
Warp before: true + true → 2 (booleans are encoded as Int 1/0).
Warp: arithmetic on a boolean is a compile error with a fix-it: true + true → Error('arithmetic on a boolean: true+true at 1:1; fix: int(true) + int(true)'), likewise (1<2) + 1 and (not 1) + 2. Booleans are still used as conditions (x = 1 < 2; if x {1} else {2}).
Decision: reject arithmetic on booleans in the analyzer, keep the Int 1/0 runtime encoding (alternatives: a distinct Kind::Bool with its own payload, which DESIGN.md's Bool semantic type calls for, but True/False are Int 1/0 at the Node boundary, pinned by tests/test_node_operators.rs (&True + &True == 2) and by every boolean-returning is! test; keep and document). Not yet: the check sees boolean literals, comparisons and not, not variables holding booleans (no semantic Bool type yet); false == 0 → 1 still compares across kinds. (test_booleans_are_not_numbers)

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.

"Impossible"

Solvable:

"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, number of t count graphemes, the same unit t#i indexes by; size t counts bytes. 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.chars; 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 (2026-09-28): exact is the default, floats are the explicit opt-in, both declaration syntaxes are allowed. Warp: (0.1+0.2)+0.3 == 0.1+(0.2+0.3) → 1; fast a=0.1; fast b=0.2; fast c=0.3; (a+b)+c == a+(b+c) → 0, likewise 0.1f. 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^10 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. 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.

Home

Philosophy •

data & code blocks

features

inventions

evaluation

keywords

iteration

tasks

examples

todo : bad ideas and open questions

⚠️ specification and progress are out of sync

Clone this wiki locally