From c749684b2faebcbafff5f9d372d6ac5892e525e8 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Wed, 5 Aug 2026 17:48:12 -0500 Subject: [PATCH] runtime: null is not a dict, and a discarded interrogative is an error (#872, #869, #867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three silent-tolerance items from the 2026-08 sweep. makes dict-miss-returns-null a deliberate decision (a missing key is a lookup miss, not a logic error), but that rationale covers a DICT. `null` is not a dict, and it was the one non-dict type on which field access silently succeeded — so a typo'd config path propagated through arbitrary depth (cfg.databse.host -> null -> null) and surfaced somewhere unrelated, or nowhere at all. Not a one-line special case: FIFTEEN field/index read sites in vm.c each carried an explicit `!= VAL_NULL` guard, so null was systematically absorbing for access. All fifteen are gone and the full suite passes unchanged — nothing in the tree depended on the absorption. A dict's own miss is still null, on purpose; what changed is that walking THROUGH a miss now fails at the miss. the literal 42, and had no effect at all: rc=0, nothing on stderr, only lint caught it. `what`, `when` and `where` are plausible variable names in exactly the domains this language targets. The issue proposed a parse-level compile error. That would have broken the REPL, which ECHOES `what is x` as `=> 5` — the two contexts are syntactically identical, and the only difference is whether the result is consumed. The compiler already knows that per statement, because compile_block and AST_PROGRAM emit OP_POP for every statement except the last. So the check keys on the DISCARD, not the syntax: the issue's repro is now a compile error, while the REPL's last statement, `eval`'s result, and interrogatives in expression position are untouched. No mode flag and no signature change. Honest limit, stated at the check: a discarded interrogative as a unit's FINAL statement is not caught here, because there it is the result. Lint's W019 still flags it. The interrogative word table moved into eigenscript.c so lint and the compiler cannot name different words for the same kind. binds tighter than `/` (per the precedence table 25 lines above in the same file), so `a[floor of (lo + hi) / 2]` parsed as `(floor of (lo + hi)) / 2` and hit the integer-index guard. Now `a[floor of ((lo + hi) / 2)]`, verified to run. tests/test_dict.eigs covers the null receiver on both access forms, the chained case, and that a dict miss is still null. New suite section [99j] covers the compile error plus the three contexts that must keep working (REPL echo, eval, expression position). Suite 3798/3798 release, 3796/3796 ASan+UBSan with detect_leaks=1, leak tally 0. Closes #872 Closes #869 Closes #867 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 35 ++++++++++++++++++++++++++++ docs/LANGUAGE_CONTRACT.md | 13 ++++++++--- src/compiler.c | 39 +++++++++++++++++++++++++++++-- src/eigenscript.c | 8 +++++++ src/eigenscript.h | 2 ++ src/lint.c | 5 +--- src/vm.c | 32 ++++++++++++------------- tests/run_all_tests.sh | 48 ++++++++++++++++++++++++++++++++++++++ tests/test_dict.eigs | 49 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 205 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4aa5ba2..06a51e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -149,6 +149,35 @@ All notable changes to EigenScript are documented here. ### Changed +- **`null.field` and `null["k"]` raise instead of yielding `null` + (#872).** The contract makes dict-miss-returns-`null` a deliberate + decision — a missing key is a lookup miss, not a logic error — but + that rationale covers a *dict*, and `null` was the one non-dict type + on which field access silently succeeded. So a typo'd config path + propagated through arbitrary depth (`cfg.databse.host` → `null` → + `null`) and surfaced somewhere unrelated, or nowhere. Walking through + a miss now fails at the miss. A dict's own miss is unchanged and still + `null`, on purpose. Fifteen access sites in `vm.c` carried the + `!= VAL_NULL` exemption; all of them are gone, and the full suite + passes unchanged — nothing depended on the absorption. + +- **A discarded interrogative is a compile error (#869).** `what is 42` + reads as an assignment, parses as a question about the literal `42`, + and had **no effect at all**: the program ran to completion at rc=0 + with nothing on stderr. Only `--lint` caught it, and `what`, `when` + and `where` are plausible variable names in exactly the domains this + language targets. Every neighbouring mistake is loud — an unresolved + name is fatal, `break` outside a loop is a compile error — so this was + the odd one out. + The check keys on the **discard**, not on the syntax, which is what + makes it safe: the REPL and `eval` compile a unit whose last statement + *is* the result, so `what is x` typed at the REPL still answers `=> 5` + and interrogatives in expression position are untouched. Only a value + nobody can read is refused. (A discarded interrogative as a unit's + final statement is therefore still lint-only.) The interrogative word + table is now shared between lint and the compiler rather than + duplicated. + - **JSON is a lossless round-trip for every number (#875).** The contract promises `num of (str of x) == x`. That held for `str of` and for nothing else: `json_encode`, `json_build` and `json_path` each @@ -270,6 +299,12 @@ All notable changes to EigenScript are documented here. naming the file used and the file shadowed. Sweep of the repo and all 15 consumer repos found zero imports whose resolution flips. +- **`LANGUAGE_CONTRACT.md`'s recommended midpoint index raised (#867).** + The Indexing promise recommended `a[floor of (lo + hi) / 2]`, but `of` + binds tighter than `/` — per the precedence table 25 lines above in + the same file — so it parsed as `(floor of (lo + hi)) / 2` and raised + `index must be an integer, got 1.5`. Now `a[floor of ((lo + hi) / 2)]`. + - **vm_run_bytecode/sandbox_run: an assembled chunk's temporal opcodes now turn history recording on themselves (#831).** `g_trace_hist` was set only by the bytecode compiler's source scan, so a descriptor diff --git a/docs/LANGUAGE_CONTRACT.md b/docs/LANGUAGE_CONTRACT.md index 33d395f8..eb81360b 100644 --- a/docs/LANGUAGE_CONTRACT.md +++ b/docs/LANGUAGE_CONTRACT.md @@ -446,7 +446,7 @@ unary and `of` are right-associative. are accepted (`a[2.0]` works), since EigenScript has a single number type; but a fractional value is never silently truncated. Because `/` always yields a double, a division-derived index must be collapsed explicitly — - `a[floor of (lo + hi) / 2]` — which keeps the rounding decision in the + `a[floor of ((lo + hi) / 2)]` — which keeps the rounding decision in the programmer's hands. A value that is fractional only through float drift (`2.9999998`) also raises, surfacing the sloppy arithmetic rather than mis-indexing. @@ -482,8 +482,15 @@ list index is a logic error. Both forms of dict access (`d.k` and `d["k"]`) agree. Use `has_key of [d, "k"]` to test membership when `null` is itself a valid stored value. -**Status:** Enforced — `tests/test_dict.eigs`, `OP_DOT_GET` / -`OP_INDEX_GET` in `vm.c`. +That rationale covers a **dict**. It does not cover `null`, which is not a +dict — so `null.k` and `null["k"]` **raise**, like field access on any other +non-dict (#872). This is what keeps a typo'd path from propagating: `d.mising` +is `null` at the miss, and walking through it (`d.mising.deeper`) fails +*there* rather than yielding `null` through arbitrary depth and surfacing +somewhere unrelated, or nowhere. + +**Status:** Enforced — `tests/test_dict.eigs` (incl. the null-receiver +cases), `OP_DOT_GET` / `OP_INDEX_GET` in `vm.c`. ## Statistics convention (library) diff --git a/src/compiler.c b/src/compiler.c index f3e146ed..818abe9d 100644 --- a/src/compiler.c +++ b/src/compiler.c @@ -189,6 +189,7 @@ static void name_set_remove(NameSet *s, const char *name) { static void compile_node(Compiler *c, ASTNode *node); static void compile_node_inner(Compiler *c, ASTNode *node); static void compile_block(Compiler *c, ASTNode **stmts, int count); +static void check_discarded_interrogative(ASTNode *stmt); /* #869 */ /* ---- Loop-context stack (#335/#336) ---- * Push/pop are strictly balanced within each AST_LOOP/AST_FOR case, so the @@ -2007,8 +2008,10 @@ static void compile_node_inner(Compiler *c, ASTNode *node) { case AST_PROGRAM: { for (int i = 0; i < node->data.program.count; i++) { compile_node(c, node->data.program.stmts[i]); - if (i + 1 < node->data.program.count) + if (i + 1 < node->data.program.count) { + check_discarded_interrogative(node->data.program.stmts[i]); /* #869 */ emit(c, OP_POP, node->line); + } } break; } @@ -3018,6 +3021,36 @@ static void compile_node_inner(Compiler *c, ASTNode *node) { } } +/* #869: a statement whose value is about to be thrown away. For an + * interrogative that is always a mistake — `what is 42` reads as an + * assignment, parses as a question about the literal 42, and had NO effect at + * all: the program ran to completion, rc=0, with no diagnostic on stderr. + * Only lint caught it. Every neighbouring mistake in the language is loud (an + * unresolved name is fatal, `break` outside a loop is a compile error), so + * this was the odd one out. + * + * The DISCARD is the signal, which is why the check lives here and not in the + * parser: the REPL and `eval` compile a unit whose LAST statement is the + * result, so `what is x` typed at the REPL still answers, and only a value + * nobody can read is refused. (A discarded interrogative as a unit's final + * statement is therefore not caught here — lint's W019 still flags it.) + * + * Called from both statement loops: AST_PROGRAM (a script's or REPL line's top + * level) and compile_block (every nested body). */ +static void check_discarded_interrogative(ASTNode *stmt) { + if (!stmt || stmt->type != AST_INTERROGATE) return; + int k = stmt->data.interrogate.kind; + if (k < 0 || k > 5) return; /* `prev of x` — lint W019 covers it */ + char msg[256]; + snprintf(msg, sizeof(msg), + "'%s is ...' is an interrogative, not an assignment — question words " + "cannot be assigned with 'is', and this statement's result is discarded", + eigs_interrogative_word(k)); + fprintf(stderr, "Compile error line %d: %s\n", stmt->line, msg); + eigs_record_first_error(stmt->line, msg); + g_parse_errors++; +} + static void compile_block(Compiler *c, ASTNode **stmts, int count) { if (count == 0) { emit(c, OP_NULL, 0); @@ -3032,8 +3065,10 @@ static void compile_block(Compiler *c, ASTNode **stmts, int count) { i + 1, count, stmts[i]->line, depth_after - depth_before, depth_before, depth_after); } - if (i + 1 < count) + if (i + 1 < count) { + check_discarded_interrogative(stmts[i]); emit(c, OP_POP, stmts[i]->line); + } } } diff --git a/src/eigenscript.c b/src/eigenscript.c index bb607f76..2081709e 100644 --- a/src/eigenscript.c +++ b/src/eigenscript.c @@ -203,6 +203,14 @@ const char* tok_type_name(TokType t) { } } +/* #869: the interrogative words, in AST_INTERROGATE kind order. Shared by + * lint's W019 and the compiler's discarded-statement check so the two cannot + * name different words for the same kind. */ +const char* eigs_interrogative_word(int kind) { + static const char *words[] = {"what", "who", "when", "where", "why", "how"}; + return (kind >= 0 && kind <= 5) ? words[kind] : "prev"; +} + const char* val_type_name(ValType t) { switch (t) { case VAL_NUM: return "num"; diff --git a/src/eigenscript.h b/src/eigenscript.h index d63967d8..3d9b45b5 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -1216,6 +1216,8 @@ void eigenscript_set_args(int argc, char **argv); * when uncaught. Declared here so extension TUs (ext_store, etc.) can route * argument/operation failures through the same strict channel as the VM. */ const char* val_type_name(ValType t); +/* #869: interrogative word for an AST_INTERROGATE kind (lint + compiler). */ +const char* eigs_interrogative_word(int kind); /* #406: the closed error-kind vocabulary. Every built-in runtime error * carries exactly one of these; `catch` binds it as the dict's "kind" * string (err_kind_name). The set is CLOSED by design — the same diff --git a/src/lint.c b/src/lint.c index abef9e77..1774a36c 100644 --- a/src/lint.c +++ b/src/lint.c @@ -1451,10 +1451,7 @@ static void check_stdlib_shadow(ASTNode *ast, const char *path, * nodes sitting directly in a statement list, so an interrogative used * inside an expression (`print of (why is x)`) is never reached. */ -static const char *interrog_word(int kind) { - static const char *words[] = {"what", "who", "when", "where", "why", "how"}; - return (kind >= 0 && kind <= 5) ? words[kind] : "prev"; -} +#define interrog_word(kind) eigs_interrogative_word(kind) /* #869: one table */ /* #736: the same silent no-op through the other door. `report of x` / * `observe of x` / `report_value of x` / `trajectory of x` over an IDENT are diff --git a/src/vm.c b/src/vm.c index acd9de47..ce1159ab 100644 --- a/src/vm.c +++ b/src/vm.c @@ -1138,7 +1138,7 @@ void jit_helper_local_idx_get(int slot, int idx) { } return; } - if (target->type != VAL_NULL) { + { rt_error(EK_TYPE, g_vm.current_line, "cannot index %s", val_type_name(target->type)); } @@ -1175,7 +1175,7 @@ void jit_helper_local_dot_get(EigsChunk *chunk, int slot, int name_idx) { } return; } - if (target && target->type != VAL_NULL) { + if (target) { const char *key = chunk->const_interns[name_idx]; rt_error(EK_TYPE, g_vm.current_line, "cannot access field '%s' on %s", @@ -1221,7 +1221,7 @@ void jit_helper_local_idx_dot_get(EigsChunk *chunk, int slot, } return; } - } else if (dict && dict->type != VAL_NULL) { + } else if (dict) { const char *key = chunk->const_interns[name_idx]; rt_error(EK_TYPE, g_vm.current_line, "cannot access field '%s' on %s", @@ -1232,7 +1232,7 @@ void jit_helper_local_idx_dot_get(EigsChunk *chunk, int slot, "index %d out of range (list length %d)", i, target->data.list.count); } - } else if (target && target->type != VAL_NULL) { + } else if (target) { rt_error(EK_TYPE, g_vm.current_line, "cannot index %s", val_type_name(target->type)); } @@ -1312,7 +1312,7 @@ void jit_helper_dot_get(EigsChunk *chunk, int name_idx) { vm_push(v); return; } - } else if (target->type != VAL_NULL) { + } else { rt_error(EK_TYPE, g_vm.current_line, "cannot access field '%s' on %s", key, val_type_name(target->type)); @@ -1918,7 +1918,7 @@ void jit_helper_index_set(void) { } } else if (target->type == VAL_DICT && idx->type == VAL_STR) { dict_set(target, idx->data.str, val); - } else if (target->type != VAL_NULL) { + } else { rt_error(EK_TYPE, g_vm.current_line, "cannot index %s for assignment", val_type_name(target->type)); } val_decref(target); val_decref(idx); @@ -2016,7 +2016,7 @@ void jit_helper_index_get(void) { rt_error(EK_INDEX, g_vm.current_line, "buffer index %d out of range (length %d)", i, target->data.buffer.count); - } else if (target->type != VAL_NULL) { + } else { rt_error(EK_TYPE, g_vm.current_line, "cannot index %s", val_type_name(target->type)); } @@ -4080,7 +4080,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { result = make_num(target->data.buffer.data[i]); else rt_error(EK_INDEX, current_line, "buffer index %d out of range (length %d)", i, target->data.buffer.count); - } else if (target->type != VAL_NULL) { + } else { rt_error(EK_TYPE, current_line, "cannot index %s", val_type_name(target->type)); } val_decref(target); val_decref(idx); @@ -4175,7 +4175,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { } } else if (target->type == VAL_DICT && idx->type == VAL_STR) { dict_set(target, idx->data.str, val); - } else if (target->type != VAL_NULL) { + } else { rt_error(EK_TYPE, current_line, "cannot index %s for assignment", val_type_name(target->type)); } val_decref(target); val_decref(idx); @@ -4205,7 +4205,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { vm_push(v); DISPATCH(); } - } else if (target->type != VAL_NULL) { + } else { rt_error(EK_TYPE, current_line, "cannot access field '%s' on %s", key, val_type_name(target->type)); } @@ -4274,7 +4274,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { } else { vm_push_slot(slot_null()); } - } else if (target && target->type != VAL_NULL) { + } else if (target) { const char *key = chunk->const_interns[name_idx]; rt_error(EK_TYPE, current_line, "cannot access field '%s' on %s", key, val_type_name(target->type)); @@ -4360,9 +4360,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { } DISPATCH(); } - if (target->type != VAL_NULL) { - rt_error(EK_TYPE, current_line, "cannot index %s", val_type_name(target->type)); - } + rt_error(EK_TYPE, current_line, "cannot index %s", val_type_name(target->type)); } vm_push_slot(slot_null()); DISPATCH(); @@ -4396,7 +4394,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { } DISPATCH(); } - } else if (dict && dict->type != VAL_NULL) { + } else if (dict) { const char *key = chunk->const_interns[name_idx]; rt_error(EK_TYPE, current_line, "cannot access field '%s' on %s", key, val_type_name(dict->type)); @@ -4405,7 +4403,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { rt_error(EK_INDEX, current_line, "index %d out of range (list length %d)", i, target->data.list.count); } - } else if (target && target->type != VAL_NULL) { + } else if (target) { rt_error(EK_TYPE, current_line, "cannot index %s", val_type_name(target->type)); } vm_push_slot(slot_null()); @@ -4447,7 +4445,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { rt_error(EK_INDEX, current_line, "index %d out of range (list length %d)", i, target->data.list.count); } - } else if (target && target->type != VAL_NULL) { + } else if (target) { rt_error(EK_TYPE, current_line, "cannot index %s for assignment", val_type_name(target->type)); } diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index bff46a6e..e873a94a 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -4279,6 +4279,54 @@ fi rm -rf "$CONT_DIR" echo "" +# [99j] Discarded interrogative is a compile error (#869). `what is 42` reads +# as an assignment, parses as a question about the literal 42, and used to run +# to completion at rc=0 with no diagnostic — the statement's entire effect was +# discarded. Only lint caught it. The check keys on the DISCARD, so the REPL +# and `eval` (whose last statement IS the result) must keep answering. +echo "[99j] Discarded interrogative (#869)" +SK_DIR=$(mktemp -d /tmp/eigs_sk869_XXXX) +printf 'what is 42\nprint of "still alive"\ncount is 1\nwhere is count\nprint of (str of count)\n' > "$SK_DIR/discard.eigs" +SK_OUT=$(./eigenscript "$SK_DIR/discard.eigs" 2>&1); SK_RC=$? +TOTAL=$((TOTAL + 1)) +if [ "$SK_RC" -ne 0 ] && echo "$SK_OUT" | grep -q "'what is ...' is an interrogative" \ + && echo "$SK_OUT" | grep -q "'where is ...' is an interrogative" \ + && ! echo "$SK_OUT" | grep -q "still alive"; then + PASS=$((PASS + 1)) + echo " PASS: a discarded interrogative aborts before the program runs (was: silent, rc=0)" +else + FAIL=$((FAIL + 1)) + echo " FAIL: discarded interrogative should be a compile error (rc=$SK_RC)" + echo "$SK_OUT" | head -5 +fi + +# The REPL's last statement IS the result, so an interrogative there answers. +SK_REPL=$(printf 'x is 5\nwhat is x\n' | ./eigenscript 2>&1) +TOTAL=$((TOTAL + 1)) +if echo "$SK_REPL" | grep -q "=> 5" && ! echo "$SK_REPL" | grep -q "Compile error"; then + PASS=$((PASS + 1)) + echo " PASS: the REPL still answers 'what is x'" +else + FAIL=$((FAIL + 1)) + echo " FAIL: the REPL must still answer an interrogative" + echo "$SK_REPL" | head -5 +fi + +# Same for `eval`, and for an interrogative used inside an expression. +printf 'z is 3\nprint of (str of (eval of "what is z"))\nprint of (str of (what is z))\n' > "$SK_DIR/live.eigs" +SK_LIVE=$(./eigenscript "$SK_DIR/live.eigs" 2>&1); SK_LIVE_RC=$? +TOTAL=$((TOTAL + 1)) +if [ "$SK_LIVE_RC" -eq 0 ] && [ "$(echo "$SK_LIVE" | head -1)" = "3" ]; then + PASS=$((PASS + 1)) + echo " PASS: eval and expression-position interrogatives are untouched" +else + FAIL=$((FAIL + 1)) + echo " FAIL: eval / expression interrogatives must keep working (rc=$SK_LIVE_RC)" + echo "$SK_LIVE" | head -5 +fi +rm -rf "$SK_DIR" +echo "" + # [99i] Uniform -Werror=switch gate (#817 follow-up; #835 extended it to # compile-bearing shell scripts). Dry-runs every compiling Makefile target # plus the audited scripts (tools/freestanding_check.sh) and asserts every diff --git a/tests/test_dict.eigs b/tests/test_dict.eigs index e821b248..31fda42f 100644 --- a/tests/test_dict.eigs +++ b/tests/test_dict.eigs @@ -84,4 +84,53 @@ loop while ci < 50: assert_eq of [churn2.keep1 + churn2.keep2, 3, "surviving keys resolve after churn"] assert_eq of [len of churn2, 2, "only survivors remain"] +# ---- #872: null is not a dict, so field access on it RAISES ---- +# LANGUAGE_CONTRACT.md makes dict-miss-returns-null a deliberate decision: a +# missing key is a lookup miss, not a logic error. That rationale covers a +# DICT. It does not cover `null`, which was the one non-dict type on which +# field access silently succeeded — so a typo'd config path propagated through +# arbitrary depth and surfaced somewhere else entirely, or nowhere. + +nl is null +c_field is 0 +k_field is "" +try: + bad is nl.foo +catch e: + c_field is 1 + k_field is e.kind +assert_eq of [c_field, 1, "N872 null.field raises"] +assert_eq of [k_field, "type_mismatch", "N872 null.field kind is type_mismatch"] + +c_index is 0 +try: + bad2 is nl["foo"] +catch e: + c_index is 1 +assert_eq of [c_index, 1, "N872 null[\"key\"] raises"] + +# The propagation case from the issue: it stops at the FIRST hop now. +c_deep is 0 +try: + bad3 is nl.a.b.c +catch e: + c_deep is 1 +assert_eq of [c_deep, 1, "N872 a chained access through null raises at the first hop"] + +# A missing key on a real dict is still null — that behavior is deliberate +# and unchanged; only null-as-a-receiver changed. +d872 is {"k": 1} +assert_eq of [d872.k, 1, "N872 a present key still reads"] +assert_true of [d872.missing == null, "N872 a dict miss is still null, on purpose"] +assert_true of [d872["missing"] == null, "N872 a dict index miss is still null"] + +# And a miss that yields null still raises when you walk THROUGH it, which is +# what makes the typo visible instead of silent. +c_walk is 0 +try: + bad4 is d872.missing.deeper +catch e: + c_walk is 1 +assert_eq of [c_walk, 1, "N872 walking through a dict miss raises"] + test_summary of null