Skip to content

Short-circuit and and or (§13.21, §14 decision 11) - #178

Merged
kaidesu merged 1 commit into
1.0from
claude/ghost-items-prioritize-f4ghko
Sep 4, 2026
Merged

kaidesu merged 1 commit into
1.0from
claude/ghost-items-prioritize-f4ghko

Conversation

@kaidesu

@kaidesu kaidesu commented Sep 4, 2026

Copy link
Copy Markdown
Member

Overview

Closes §15's #1. §8.4 asked for both operands to be evaluated and the evaluator obeyed, so this was a stance rather than drift — which is why §14 decision 11 had to reverse it before this patch could exist. The evidence behind the ranking: eight defects in the first library written against Ghost, two of them shipped, every one a null guard written the way Python, Ruby, JavaScript and PHP all teach it, crashing on the dereference it existed to prevent.

target = null
target == null or target.hint == ''
// before: property error - cannot read property `hint` of null
// after:  true

// Chisel's Keymap.dispatch(), which crashed on every unbound keypress
route == null or !passes(route.middleware)
// before: property error - cannot read property `middleware` of null
// after:  true

The narrow reversal decision 11 described held: the truth table is untouched, both operands are still booleans, and the one observable loosening is that an unreached operand is no longer type-checked.

false and 1   // before: type error - cannot use `and` between boolean and number
              // after:  false

Changes

Changed

  • evaluateInfix (evaluator/infix.go) routes and/or to a new evaluateLogicalInfix (evaluator/boolean.go) as soon as the left operand is evaluated, before the right one is touched. It requires the left operand to be a boolean, returns immediately when that settles the answer (false and x, true or x), and otherwise evaluates the right operand and answers with it — once the left has not decided, the result is the right one, so there is no second truth table to keep in sync.
  • and/or are gone from evaluateBooleanInfix. It is handed both operands already evaluated and so cannot make this decision; the cases would be dead code.
  • Error wording now names the side at fault — cannot use and with null on the left, via a new logicalOperandError. This is the part decision 11 did not anticipate: a wrong left operand can no longer be reported as "between null and boolean", because the right operand was deliberately never evaluated and naming a type it might have had would be inventing one. The null case carries help: compare it first, as in x != null``.
  • §8.4 documents short-circuiting as the rule; §13.21 and §14 decision 11 are marked done with what landed; §15's table moves parse prefix expressions #1 to closed and names parse infix operators #2 as next.
  • foldBooleanInfix (optimizer/fold.go) is unchanged apart from its comment — it folds two literal booleans, where there is no evaluation to skip either way.

Added

  • evaluator/logical_test.go.

Additional context

The wording change is a real improvement, not just a consequence. A bare truthy guard now fails at the operator instead of at the null dereference downstream of it:

x = null
if (x and x.foo) { }
// before: property error: cannot read property `foo` of null   ← points at the guard's victim
// after:  type error: cannot use `and` with null on the left
//         help: compare it first, as in `x != null`            ← points at the mistake

and/or stay boolean-only, so this shape is still an error — it is a truthy guard in a language that has never had truthy operators. It just says so now.

Verification.

  • evaluator/logical_test.go covers the truth table (unchanged), short-circuiting proved two ways — a right operand that raises, and one whose side effect is counted and must not happen — the reached/unreached error wording with exact positions, and the null help line.
  • All 41 programs in examples/ produce identical output before and after. mud.gs is the one exception and is not a behaviour difference: it is a while (true) game loop on console.read() that both builds run into the timeout, byte-identical for the first 2.8 MB, differing only in how many frames each rendered inside the window.
  • Studio's 132-case suite — the codebase that reported this — passes unchanged against the new interpreter.
  • go build ./..., go vet ./..., gofmt -l . and go test ./... are all clean.

Next on §15 is #2 (§13.22, a method's name shadowing a same-named import), which wants the same class-construction check as #4 (§13.18) and should land with it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EGsNMRiKwWmvoizxD619zA


Generated by Claude Code

§8.4 asked for both operands to be evaluated and the evaluator obeyed, so
this was a stance rather than drift. §15 ranked it first: eight defects in
the first library written against Ghost, two of them shipped, every one a
null guard written the way Python, Ruby, JavaScript and PHP all teach it
and crashing on the dereference it existed to prevent.

    target = null
    target == null or target.hint == ''
    // was: property error, cannot read property `hint` of null
    // now: true

evaluateInfix routes `and`/`or` to evaluateLogicalInfix as soon as the left
operand is evaluated, before the right one is touched. It requires the left
operand to be a boolean, returns immediately when that settles the answer
(`false and x`, `true or x`), and otherwise evaluates the right operand and
answers with it - once the left has not decided, the result *is* the right
one. The two cases are gone from evaluateBooleanInfix, which is handed both
operands already evaluated and so cannot make this decision; they would be
dead code. foldBooleanInfix still folds two literal booleans, where there is
no evaluation to skip either way, and only its comment changed.

The narrow reversal decision 11 described held: the truth table is
untouched, both operands are still booleans, and the one observable
loosening is that an unreached operand is no longer type-checked, so
`false and 1` is now false where it was a type error.

What the decision did not anticipate is that the error wording had to move
with it. A wrong left operand can no longer be reported as "between null and
boolean" - the right operand was deliberately never evaluated, and naming a
type it might have had would be inventing one. logicalOperandError names the
side at fault instead, on either side, and suggests comparing first when the
operand is null. That reads better than what it replaced: a bare
`if (x and x.foo)` now fails at the `and` with a type error rather than
dying on the null dereference downstream of it.

Tested in evaluator/logical_test.go: the truth table, short-circuiting
proved both by a right operand that raises and by one whose side effect is
counted and must not happen, the reached/unreached error wording with
positions, and the null help line. All 41 programs in examples/ produce
identical output before and after - mud.gs differs only in how many frames
its non-terminating interactive loop renders inside the timeout, byte
identical up to that point. Studio's 132-case suite, the codebase that
reported this, passes unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGsNMRiKwWmvoizxD619zA
@kaidesu
kaidesu marked this pull request as ready for review September 4, 2026 08:15
@kaidesu
kaidesu merged commit dc88fc1 into 1.0 Sep 4, 2026
2 checks passed
@kaidesu
kaidesu deleted the claude/ghost-items-prioritize-f4ghko branch September 4, 2026 08:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants