Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,41 @@ All notable changes to EigenScript are documented here.

## [Unreleased]

### Added

- **Lint `W022`: over-arity literal calls (#733).** With
`define two(a, b)`, `two of [1, 2, 99]` binds `a=1, b=2` and silently
DISCARDS `99` — rc=0, no diagnostic at any stage (under-arity at
least null-fills and tends to fail later on a type error). W022 flags
a bare literal argument list with more elements than the callee's
parameters, conservatively: only when the callee name has exactly one
`define` in the file and no other binding anywhere (assignment,
param, loop/comprehension var, catch name, list-pattern name, import,
or a match-pattern identifier all poison it). One-parameter callees
never fire — a 2+-element bare list binds WHOLE to a single param by
the #405 arity-1 carve-out (nothing is dropped; it's the variadic
idiom), and `define f()`/`define f` both carry the implicit `n`, so
every define has arity ≥ 1. Built on a new `LINT_FOR_EACH_CHILD`
generic AST child iterator whose switch has no `default:` arm, so
`-Werror=switch` forces new node kinds to update it.

### Fixed

- **The AI-facing spread-rule docs now state the arity-sensitive rule
the runtime actually has (#733).** `docs/llms.txt`'s "#1 TRAP"
paragraph still described the PRE-#405 semantics (claimed
`f of [x]` binds the whole list — the opposite of today's runtime),
and its "which spreads" framing predicted the wrong binding for every
2+-element call to a 1-parameter function (`one of [5, 6]` binds
`a = [5, 6]`, not `a = 5`). The call section now states both halves —
bare-list-is-an-arg-list at the call site, arity-1 re-collection at
the binding — plus the silent over-arity behavior and W022.
`CLAUDE.md`'s always-on rule gains the same carve-out sentence
(docs/SPEC.md already had it). llms.txt also gains the four gaps the
#733 experiment hit: `lib/` is NOT ambient (`import list` namespaced
vs `load_file of "lib/list.eigs"` bare), lambda syntax, `sort_by`
(a builtin, no import), and the comprehension `if` clause.

- **The leftover-token parse error diagnoses the actual mistake instead
of always saying "one statement per line" (#732).** The four most
likely model/newcomer mistakes — `print("hello")`, `def f(a):`,
Expand Down
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,12 @@ Always-on:
EVERY count — `f of []` zero args, `f of [x]` one arg (the element,
not the list), `f of [a, b]` two. To pass a literal list whole,
parenthesise (#355): `f of ([x])`. Lint W017 flags the 1-element bare
form (pre-#405 it meant the opposite). (More `.eigs`-writing gotchas:
form (pre-#405 it meant the opposite). **Arity-1 carve-out (#733)**:
that rule describes the call site, not the binding — a 1-parameter
callee re-collects a 2+-element arg list WHOLE (`one of [5, 6]` binds
`a = [5, 6]`, not `a = 5`; this is what keeps `len of [1, 2]`
working). Over-arity on 2+-param callees is silently dropped — W022
flags it for same-file callees. (More `.eigs`-writing gotchas:
the `write-eigenscript` skill.)
- **A semantics change must update `docs/SPEC.md` + `docs/COMPARISON.md` in
the same PR** — `tests/test_doc_examples.py` runs their example/output
Expand Down
1 change: 1 addition & 0 deletions docs/DIAGNOSTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ a code's meaning never changes, and retired codes are not reused.
| `W019` | warning | An interrogative used as a **bare statement** — `why is "..."`, `what is x`, `prev of y` at statement level — evaluates and **discards** its result: a silent no-op (#583). Question words (`what/who/when/where/why/how`) cannot be assigned with `is` — the "assignment" is the interrogative expression form — so when a same-named binding exists in scope the statement is almost certainly a mistaken assignment (the real hit: a catch handler "reassigning" a `local why` that silently kept its stale value). An interrogative inside an expression (`print of (why is x)`, `r is prev of y`) is never flagged. Extended by #736 to the observer **query** forms over an ident — `report of x`, `report_value of x`, `observe of x`, `trajectory of x` at statement level — which are the same silent no-op through the other door: they print nothing and raise nothing, so silence is indistinguishable from "the observer had nothing to say". Zero false positives by construction: over an ident these are compiler-resolved special forms that never reach a user function even when one shadows the name (#459, see `W013`), and a non-ident argument (`report of (x + 0.0)`) is an ordinary call the check never sees. |
| `W020` | warning | An `unobserved:` block in which **every** assignment targets a dict field or list element (`d.k is ...`, `xs[i] is ...`) — a provable no-op (#655). Observer bookkeeping is gated on the named env path, so a dict field is never observed and there is nothing to skip; the in-place numeric mutation the block used to enable became unconditional with NaN-boxing B-3a (`dict_set_cached_immediate`), so it costs nothing to drop the block. This shape was true when written and expired silently, which is why it needs a lint — our own README shipped the dead form as its headline example for two months. Conservative by construction: `g_unobserved_depth` is a global, so a **call** inside the block runs the callee unobserved too and suppresses the warning; any plain-variable assignment, or a name-binding form (`for` / listcomp / `catch` / `match`), also suppresses it. Only a provably inert block fires. |
| `W021` | hint | Function definition shadows a **public stdlib function** from a module the file never imported (`define 'median' shadows lib/stats.eigs 'median' (import stats to use it)`) — a discoverability nudge toward `lib/*.eigs` (#591), sibling of `W013` (which covers compiled-in builtins; a name that is both stays `W013`-only). The name table is scraped from the public top-level defines of the same `lib/` directories the import resolver searches; the hint stays silent when the module is imported, and when the linted file *is* the module that ships the name. Name-only matching has false positives (a deliberately-different local `mean`), so this is hint-severity: advisory, **never fails `--lint`** under either `--lint-level`, and suppressible like any other code. |
| `W022` | warning | A bare literal argument list with **more elements than the callee's parameters** — with `define two(a, b)`, `two of [1, 2, 99]` binds `a=1, b=2` and silently DISCARDS `99` (rc=0, no diagnostic at any stage; under-arity at least null-fills and tends to fail later) (#733). Conservative by construction: fires only when the callee name provably has one meaning in the file — exactly one `define` of it anywhere and no other binding (assignment, param, lambda param, loop/comprehension var, catch name, list-pattern name, import, or any identifier inside a `match` pattern poisons the name). One-parameter callees are exempt by the #405 semantics themselves: a 2+-element bare list binds WHOLE to a single parameter — nothing is dropped, and that shape is the deliberate variadic idiom (`reverse of [1, 2, 3]`). Parenthesized lists (`f of ([...])`) are a single argument and never fire. |

The human linter output carries the code inline:

Expand Down
45 changes: 34 additions & 11 deletions docs/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,24 @@
## Calls use `of`, never `f(x)`

- Apply a function or builtin with `of`: `print of x`, `sqrt of n`, `len of xs`.
- Multiple arguments are passed as a list, which spreads: `f of [a, b]` calls `f(a, b)`.
- **THE #1 TRAP: `f of [x]` (a ONE-element list) does NOT spread** — it binds the
whole list to the first parameter. For a single argument to a multi-parameter
(or defaulted) function, write `f of (x)`. A bare single value `f of x` is
also fine. This silently produces wrong results, not an error.
- A **bare literal list** after `of` is an argument list at every element
count: `f of []` zero arguments, `f of [x]` ONE argument (x itself, not a
1-element list), `f of [a, b]` two.
- **THE #1 TRAP — the receiving side is arity-sensitive.** A 1-parameter
callee has only one slot, so a 2+-element argument list does NOT
distribute into it: the whole list re-collects and binds to that one
parameter. With `define one(a)`: `one of [5]` binds `a = 5`, but
`one of [5, 6]` binds `a = [5, 6]` (not `a = 5` with `6` dropped). This
carve-out is what makes `len of [1, 2]` return `2` and
`reverse of [1, 2, 3]` reverse the list — but it means you cannot predict
a binding from the call site alone; you need the callee's arity.
- **Over-arity is SILENT** on 2+-parameter callees: with `define two(a, b)`,
`two of [1, 2, 99]` drops `99` with no error at any stage (lint `W022`
catches it when the callee is defined in the same file). Under-arity
null-fills, also silently.
- **Parentheses always mean exactly one argument**: `f of ([a, b])` passes the
literal 2-element list as one argument (the escape hatch). `f of []` is the
zero-argument form.
literal 2-element list as one argument (the escape hatch), and `f of (x)`
is an unambiguous single argument.
- `of` binds tighter than infix arithmetic: `sqrt of x + 1` means `(sqrt of x) + 1`.
Parenthesize the operand when you mean otherwise: `sqrt of (x + 1)`.

Expand Down Expand Up @@ -117,6 +127,10 @@ print of ("sqrt(2) = " + (str of (newton_sqrt of 2))) # sqrt(2) = 1.4142135623
xs is [1, 2, 3]
append of [xs, 4] # in-place
doubled is [v * 2 for v in xs] # comprehension (its var leaks!)
evens is [v for v in xs if v % 2 == 0] # comprehension filter clause
inc is (x) => x + 1 # lambda: params NEED the parens
add is (a, b) => a + b
srt is sort_by of [xs, (v) => 0 - v] # sort by key fn (builtin, no import)
d is {"a": 1, "b": 2}
print of d["a"]

Expand Down Expand Up @@ -149,10 +163,19 @@ match x:
## Before hand-rolling a helper

The runtime has ~255 builtins and `lib/` covers a lot. `ord`/`chr`/`str_lower`/
`str_upper`/`char_at`/`index_of`/`hex of [n, nibbles]` are builtins (there is no
bare `lower` — it's `str_lower`). `lib/` has `string.pad_left`, `format.fmt_*`,
`checksum.crc32`, and civil-date math in `datetime`. See docs/BUILTINS.md and
docs/STDLIB.md before writing your own.
`str_upper`/`char_at`/`index_of`/`hex of [n, nibbles]`/`sort_by` are builtins
(there is no bare `lower` — it's `str_lower`; `sort_by of [items, key_fn]` is
the record-shaped sort and needs no import). `lib/` has `string.pad_left`,
`format.fmt_*`, `checksum.crc32`, civil-date math in `datetime`, and
`list.filter`/`list.reduce`. **`lib/` functions are NOT ambient** — reach them
one of two ways:

```eigenscript
import list # namespaced: list.filter of [xs, fn]
load_file of "lib/list.eigs" # bare names: filter of [xs, fn]
```

See docs/BUILTINS.md and docs/STDLIB.md before writing your own.

## The generation-validation ladder (always run what you generate)

Expand Down
Loading
Loading