From 906c664a654bac100ac17786d62c72b3fc2753c5 Mon Sep 17 00:00:00 2001 From: bigmerge Date: Sat, 15 Aug 2026 21:47:32 -0500 Subject: [PATCH 1/3] compiler: a missing import is an error, not an empty string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit import "../../NOPE/does_not_exist.el" compiled CLEANLY — exit 0, empty stderr, and a program silently missing everything it imported. resolve_imports did `fs_read(src_path)` and used the result without checking. fs_read returns "" both for "file is empty" and "file does not exist", so a typo, a moved file, or a relative path resolved from the wrong working directory all produced a successful build of nothing. It caused a real wrong conclusion during test-framework work: a bisection run from a subdirectory where ../../runtime/ did not resolve produced ELEVEN consecutive "successful" compiles that had included no runtime at all, and the results were believed before anyone noticed. Missing dependency, confident success — the same shape as a test suite reporting pass for tests that never ran, and as a benchmark reporting 0us because the optimiser deleted the loop. fs_exists separates the two cases, so a legitimately empty file still resolves to "" and is fine. A path that does not exist now prints the resolved path and exits 1, which is what build scripts check. Verified: - bad import: exit 1 (was 0), message names the resolved path - elc-cli.el still compiles, self-hosting fixpoint byte-identical - neuron's full soul amalgam regeneration: exit 0, 405ms, output byte-identical at 1,270,212 bytes --- lang/el-compiler/src/compiler.el | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lang/el-compiler/src/compiler.el b/lang/el-compiler/src/compiler.el index b9647bb..14c3ccd 100644 --- a/lang/el-compiler/src/compiler.el +++ b/lang/el-compiler/src/compiler.el @@ -419,6 +419,22 @@ fn resolve_imports(src_path: String) -> String { if !str_eq(already, "") { return "" } state_set(seen_key, "1") + // A missing file must be a hard error, never an empty string. + // + // fs_read returns "" both for "file is empty" and "file does not exist", and + // this function used the value without distinguishing them. So a broken + // import path — a typo, a moved file, a relative path resolved from the + // wrong working directory — compiled CLEANLY: exit 0, empty stderr, and a + // program silently missing everything it imported. Observed 2026-08-15: + // eleven consecutive "successful" compiles that had included no runtime at + // all, and a wrong conclusion drawn from them before anyone noticed. + // + // Missing dependency, confident success. fs_exists separates the two cases, + // so a genuinely empty file still resolves to "" and is fine. + if !fs_exists(src_path) { + println("elc: cannot resolve import: " + src_path) + exit_program(1) + } let source: String = fs_read(src_path) let dir: String = dirname_of(src_path) let lines: [String] = str_split(source, "\n") From b55e6bfd53077f8dff57bfe63171cad44ff9267d Mon Sep 17 00:00:00 2001 From: bigmerge Date: Sat, 15 Aug 2026 21:51:35 -0500 Subject: [PATCH 2/3] codegen: either side Int is enough for == and !=, not both MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit let a: Int = 5 getint(5) == a -> str_eq(getint(5), a) SIGSEGV getint(5) == 5 -> getint(5) == 5 fine A function call whose return type codegen cannot infer poisoned the operator, and a declared Int on the other side did not save it. str_eq then read an integer as a char* and segfaulted. Only an integer LITERAL on one side forced the numeric form, which is why the bug stayed invisible: the common case happened to be safe. The check required BOTH operands to be provably Int: if is_int_expr(left) { if is_int_expr(right) { numeric } } Loosening to OR is strictly safer, not a trade: - when one side is a known Int, str_eq is ALWAYS wrong — it dereferences that integer — while numeric comparison is at worst a wrong answer on a program that was already ill-typed; - when neither side is Int nothing changes at all, so string comparison is untouched. Found by the test-framework agent while building the benchmark harness; it correctly declined to fix it mid-phase since it is a codegen semantics change. VERIFIED, because a semantics change earns more than an assertion: - 15/15 on a dedicated operator suite covering string literals, string vars, string-returning calls, mixed var/call, and != in every combination. The pre-change compiler scores 0/15 on the same file: it segfaults before printing anything. - self-hosting fixpoint byte-identical - the ONLY difference in the compiler's own generated C is the intended one: a nested if becoming two sequential ifs, in EqEq and NotEq. Nothing else moved. - neuron's full soul amalgam regenerates in 400ms, exit 0, output BYTE-IDENTICAL at 1,270,212 bytes - test_math 13/13, test_string 27/27, test_core 10/10, test_text 12/12 — 62 tests, 190 assertions, zero failures NOT fixed here, same family, flagged for a decision: Bool PARAMETERS are not tracked as int-like, so `cond == want` between two Bool params still lowers to str_eq and segfaults. Found while writing this commit's own test harness — the first version of it crashed on exactly that, on both the old and new compiler. It needs the same treatment, and it wants its own change. --- lang/el-compiler/src/codegen.el | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/lang/el-compiler/src/codegen.el b/lang/el-compiler/src/codegen.el index fe1056f..1dd89ae 100644 --- a/lang/el-compiler/src/codegen.el +++ b/lang/el-compiler/src/codegen.el @@ -862,10 +862,23 @@ fn cg_expr(expr: Map) -> String { // arithmetic BinOp (or vice-versa). Without this check the // fallthrough to str_eq produces str_eq(int_value, int_value) // which reads the integer as a char* and segfaults. + // EITHER side provably Int is enough. Requiring BOTH meant a call + // whose return type codegen cannot infer poisoned the operator: + // getint(5) == a -> str_eq(getint(5), a) + // even with `a` declared Int. str_eq then reads an integer as a + // char* and segfaults. Only an integer LITERAL on one side forced + // the numeric form, so the bug was invisible in the common case. + // + // Loosening to OR is strictly safer: when one side is a known Int, + // str_eq is always wrong (it dereferences that int), while numeric + // comparison is at worst a wrong answer on an already ill-typed + // program. When neither side is Int nothing changes, so string + // comparison is untouched. if is_int_expr(left) { - if is_int_expr(right) { - return "(" + left_c + " == " + right_c + ")" - } + return "(" + left_c + " == " + right_c + ")" + } + if is_int_expr(right) { + return "(" + left_c + " == " + right_c + ")" } // Float literal or negative float literal: use plain == (bit-equal // el_val_t comparison). This handles `r0 == 3.0`, `neg == -3.0`, etc. @@ -921,10 +934,12 @@ fn cg_expr(expr: Map) -> String { } // Same mixed Ident/BinOp fix as EqEq: use is_int_expr to detect // integer-typed operands before falling through to !str_eq. + // Either side Int is enough — see the EqEq note above. if is_int_expr(left) { - if is_int_expr(right) { - return "(" + left_c + " != " + right_c + ")" - } + return "(" + left_c + " != " + right_c + ")" + } + if is_int_expr(right) { + return "(" + left_c + " != " + right_c + ")" } // Float-typed operands use plain != (bit-equal comparison). if is_float_expr(left) { From b5a0a729e6f80bf2f239602bb9d701291faecd42 Mon Sep 17 00:00:00 2001 From: bigmerge Date: Sat, 15 Aug 2026 21:54:10 -0500 Subject: [PATCH 3/3] codegen: Bool is int-like, so Bool comparisons stop lowering to str_eq MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fn check(label: String, cond: Bool, want: Bool) -> Void { if cond == want { ... } -> if (str_eq(cond, want)) SIGSEGV } Bool has always been an integer in the value model — type_to_c maps Bool to "int", and el_runtime.h states "Bool -> el_val_t (0 = false, nonzero = true)". But Bool names were registered NOWHERE: build_int_names_for_params tracked Int and Float params, and the `let` path tracked Int and Float bindings. Neither knew about Bool. So comparing two Bools fell through to str_eq, which dereferenced 0 or 1 as a char* and segfaulted immediately. This is the third instance of one family found tonight, after el #137 (a call on either side of == poisoned the operator) and el #136 (a missing import compiled clean). All three are the same shape: something the compiler could not type, silently handled as a string. Found while writing #137's own test harness — the first version of that harness crashed on exactly this, on both the old and new compiler, which is how it surfaced. A test harness that cannot compare two Bools is a good way to notice. VERIFIED: - the harness that segfaulted on every prior compiler (exit 139, no output) now runs clean: 14 passed, 0 failed - self-hosting fixpoint byte-identical - the compiler's own generated C differs by 8 lines — only the intended registration - neuron's full soul amalgam regenerates in 424ms, exit 0, BYTE-IDENTICAL - test_math 13/13, test_string 27/27, test_core 10/10, test_text 12/12 Adds tests/runtime/operator_typing_test.el, the 15-case suite from #137, so this family is covered going forward rather than rediscovered. --- lang/el-compiler/src/codegen.el | 14 +++++++++++ lang/tests/runtime/operator_typing_test.el | 28 ++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 lang/tests/runtime/operator_typing_test.el diff --git a/lang/el-compiler/src/codegen.el b/lang/el-compiler/src/codegen.el index 1dd89ae..01f18c1 100644 --- a/lang/el-compiler/src/codegen.el +++ b/lang/el-compiler/src/codegen.el @@ -1510,6 +1510,11 @@ fn cg_stmt(stmt: Map, indent: String, declared: [String]) -> [Strin if str_eq(ltype, "Int") { add_int_name(name) } + // Same as params: Bool is an int in the value model. Without this a + // `let ok: Bool = ...` compared to another Bool lowered to str_eq. + if str_eq(ltype, "Bool") { + add_int_name(name) + } if str_eq(ltype, "Float") { add_float_name(name) } @@ -3127,6 +3132,15 @@ fn build_int_names_for_params(params: [Map]) -> Bool { if str_eq(ptype, "Int") { add_int_name(pname) } + // Bool is an integer in the value model (type_to_c maps Bool -> "int"; + // el_runtime.h: "Bool -> el_val_t (0 = false, nonzero = true)"), but + // Bool names were registered nowhere. So `cond == want` between two + // Bool params fell through to str_eq and dereferenced 0 or 1 as a + // char* — an immediate segfault. Track them as int-like, which is what + // they are. + if str_eq(ptype, "Bool") { + add_int_name(pname) + } if str_eq(ptype, "Float") { add_float_name(pname) } diff --git a/lang/tests/runtime/operator_typing_test.el b/lang/tests/runtime/operator_typing_test.el new file mode 100644 index 0000000..00ffe80 --- /dev/null +++ b/lang/tests/runtime/operator_typing_test.el @@ -0,0 +1,28 @@ +fn getstr(x: String) -> String { return x } +fn getint(x: Int) -> Int { return x } +fn ok(label: String) -> Void { println("ok " + label) } +fn bad(label: String) -> Void { println("FAIL " + label) } + +let s1: String = "hello" +let s2: String = "hello" +let s3: String = "world" +let i1: Int = 5 +let i2: Int = 5 +let i3: Int = 9 + +if "abc" == "abc" { ok("str literal eq") } else { bad("str literal eq") } +if "abc" == "xyz" { bad("str literal ne") } else { ok("str literal ne") } +if s1 == s2 { ok("str var eq") } else { bad("str var eq") } +if s1 == s3 { bad("str var ne") } else { ok("str var ne") } +if getstr("hi") == "hi" { ok("str call vs literal") } else { bad("str call vs literal") } +if s1 == getstr("hello") { ok("str var vs call") } else { bad("str var vs call") } +if s1 == getstr("nope") { bad("str var vs call ne") } else { ok("str var vs call ne") } +if i1 == i2 { ok("int var eq") } else { bad("int var eq") } +if i1 == i3 { bad("int var ne") } else { ok("int var ne") } +if getint(5) == i1 { ok("int call vs var") } else { bad("int call vs var") } +if getint(9) == i1 { bad("int call vs var ne") } else { ok("int call vs var ne") } +if s1 != s3 { ok("str NOTEQ") } else { bad("str NOTEQ") } +if s1 != s2 { bad("str NOTEQ same") } else { ok("str NOTEQ same") } +if i1 != i3 { ok("int NOTEQ") } else { bad("int NOTEQ") } +if getint(9) != i1 { ok("int call NOTEQ") } else { bad("int call NOTEQ") } +println("done")