diff --git a/CHANGELOG.md b/CHANGELOG.md index e4aa5ba..06a51e8 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 33d395f..eb81360 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 f3e146e..818abe9 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 bb607f7..2081709 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 d63967d..3d9b45b 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 abef9e7..1774a36 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 acd9de4..ce1159a 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 bff46a6..e873a94 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 e821b24..31fda42 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