Skip to content

runtime: null is not a dict, and a discarded interrogative is an error (#872, #869, #867) - #898

Merged
InauguralPhysicist merged 1 commit into
mainfrom
fix/872-869-867-silent-tolerance
Aug 5, 2026
Merged

runtime: null is not a dict, and a discarded interrogative is an error (#872, #869, #867)#898
InauguralPhysicist merged 1 commit into
mainfrom
fix/872-869-867-silent-tolerance

Conversation

@InauguralPhysicist

Copy link
Copy Markdown
Collaborator

Three silent-tolerance items from the 2026-08 sweep.

#872null was the one non-dict you could read fields off

The contract makes dict-miss-returns-null a deliberate decision: a missing key is a lookup miss, not a logic error. 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.hostnullnull) and surfaced somewhere unrelated, or nowhere.

Not a one-line special case. Fifteen field/index read sites in vm.c each carried an explicit != VAL_NULL guard — null was systematically absorbing for access. All fifteen are gone, and the full suite passes unchanged, which is the useful finding: 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 fails at the miss:

d.missing          # null, as documented
d.missing.deeper   # now raises here, instead of null-ing onward

#869 — the issue's proposed fix would have broken the REPL

what is 42 reads as an assignment, parses as a question about 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 making soft-keyword assignment a parse-level compile error. I checked the REPL first — it echoes what is x as => 5. A parse-level error would have broken the language's most natural interrogative use, and eval of "what is x" with it. The two contexts are syntactically identical; the only difference is whether the result is consumed.

The compiler already knows that per statement: compile_block and AST_PROGRAM emit OP_POP for every statement except the last. So the check keys on the discard, not the syntax.

$ eigenscript discard.eigs
Compile error line 1: 'what is ...' is an interrogative, not an assignment — question
words cannot be assigned with 'is', and this statement's result is discarded
Compile error line 4: 'where is ...' is an interrogative, not an assignment — ...
2 compile error(s) — aborting        # rc=1, and "still alive" never prints

while all three live contexts are untouched:

eigs> what is x          => 5                          # REPL: last statement IS the result
eval of "what is z"      3                             # same
print of (str of (what is y))   7                      # expression position never reached the check

No mode flag, no signature change. Honest limit, stated at the check: a discarded interrogative as a unit's final statement isn't 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.

#867 — the documented midpoint index raised

of binds tighter than / (per the precedence table 25 lines above in the same file), so the recommended 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.

Verification

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.

  • Release suite: 3798/3798
  • ASan + UBSan, detect_leaks=1: 3796/3796, leak tally 0

Closes #872
Closes #869
Closes #867

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings August 5, 2026 22:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR tightens EigenScript’s runtime/compiler diagnostics by removing silent tolerance in two previously “absorbing” cases (null as a field/index receiver, and discarded interrogatives), and fixes a documented precedence pitfall in the language contract.

Changes:

  • Runtime: make null.field / null["k"] raise (while preserving dict-miss-returns-null for real dicts).
  • Compiler: emit a compile error when an interrogative statement is compiled in a discarded position (while keeping REPL/eval/expression-position interrogatives working).
  • Docs: correct the midpoint-index idiom in LANGUAGE_CONTRACT.md and document the new null-receiver access semantics; record changes in CHANGELOG.md.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/test_dict.eigs Adds regression tests for null receiver field/index access raising and for chained access stopping at first null.
tests/run_all_tests.sh Adds a new suite section asserting discarded interrogatives compile-error while REPL/eval paths remain live.
src/vm.c Removes VAL_NULL exemptions from many field/index read sites so null no longer silently absorbs reads.
src/compiler.c Adds a compile-stage check that flags discarded interrogative statements as compile errors.
src/lint.c Switches interrogative word naming to a shared runtime table to avoid drift vs compiler.
src/eigenscript.c Introduces eigs_interrogative_word() shared by lint and compiler.
src/eigenscript.h Exposes eigs_interrogative_word() for cross-TU use.
docs/LANGUAGE_CONTRACT.md Fixes midpoint-index example and documents null receiver access raising.
CHANGELOG.md Notes the behavior changes and documentation fix in the changelog.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/vm.c
Comment on lines 4143 to 4147
} 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));
}
(#872, #869, #867)

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) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 5, 2026 22:59
@InauguralPhysicist
InauguralPhysicist force-pushed the fix/872-869-867-silent-tolerance branch from 1baee28 to c749684 Compare August 5, 2026 22:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.

Suppressed comments (4)

src/vm.c:1318

  • This helper now raises for null on dot read (jit_helper_dot_get), but dot assignment still appears to tolerate a VAL_NULL receiver (see jit_helper_dot_set’s else if (target->type != VAL_NULL) a few lines above this function). That leaves null.k is v as a silent no-op even though null.k / null["k"] are now loud; consider removing the VAL_NULL exemption in the DOT_SET paths for consistency with “null is not a dict”.
            vm_push(v);
            return;
        }
    } else {
        rt_error(EK_TYPE, g_vm.current_line,
            "cannot access field '%s' on %s",
            key, val_type_name(target->type));

src/vm.c:4451

  • In CASE(LOCAL_IDX_DOT_SET), the non-dict list item guard still exempts VAL_NULL (dict && dict->type != VAL_NULL a few lines above this branch). That means xs[i].field is v can still silently no-op when xs[i] is null, which is inconsistent with the new “null is not a dict” behavior for chained reads and index assignment.
                rt_error(EK_INDEX, current_line, "index %d out of range (list length %d)",
                              i, target->data.list.count);
            }
        } else if (target) {
            rt_error(EK_TYPE, current_line, "cannot index %s for assignment",
                          val_type_name(target->type));
        }

src/vm.c:4211

  • CASE(DOT_GET) now raises on VAL_NULL, but CASE(DOT_SET) still has a target->type != VAL_NULL guard (silent no-op when assigning through null). Given the contract update in this PR (“null is not a dict”), dot assignment on null should likely raise too to prevent typo’d config paths from silently discarding writes.
                vm_push(v);
                DISPATCH();
            }
        } else {
            rt_error(EK_TYPE, current_line, "cannot access field '%s' on %s",
                key, val_type_name(target->type));
        }

tests/run_all_tests.sh:4321

  • The new [99j] check only asserts the first output line of live.eigs is 3, so a regression in the expression-position interrogative (print of (str of (what is z))) could slip by as long as eval still works. Checking both printed lines will make this test actually cover both required contexts.
# 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"

@InauguralPhysicist
InauguralPhysicist merged commit 4658c45 into main Aug 5, 2026
19 checks passed
@InauguralPhysicist
InauguralPhysicist deleted the fix/872-869-867-silent-tolerance branch August 5, 2026 23:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants