diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 262bd5d3..44e2fb1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -206,6 +206,11 @@ jobs: zig build test -Doptimize=${{ matrix.mode }} echo "TEST_SECONDS=$((SECONDS - s))" >> "$GITHUB_ENV" + # M1.0.15 — Etch test-runner acceptance corpus: the Zig driver over the + # `.etch` fixtures + the `etch_test` shim run over the green fixtures. + - name: zig build test-etch + run: zig build test-etch -Doptimize=${{ matrix.mode }} + # M0.2 / E5 — bindgen-verify gate. Regenerates the Vulkan + # Wayland bindings and asserts `git diff --quiet` on # `bindings/generated/` + `src/core/platform/`. Any drift diff --git a/CLAUDE.md b/CLAUDE.md index 0503045d..97ae9149 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,9 +11,9 @@ knowledge base — see § Quick links spec. |---|---| | Phase | 1 (Etch ↔ ECS) | | Current milestone | (none — between milestones) | -| Last released tag | `v0.10.14-entity-scoped-events` | +| Last released tag | `v0.10.15-test-runner` | | Active branch | `main` | -| Next planned milestone | M1.0.15 — test-runner + identity (execute `test "X" { }` blocks + a separate symbol namespace resolving the `test "Foo"`/`component Foo` collision → `E0101`). M1.0 (Etch ↔ ECS interpreter) is **21 sub-milestones** (M1.0.0–M1.0.20), **NOT complete**: M1.0.0–M1.0.14 closed; M1.0.15–M1.0.20 close the remaining EBNF v0.6 execution gaps (criterion C1.6), Etch-closure tag at M1.0.20. Await-family partition: `entity_event` + payload filters (both event targets) delivered in M1.0.14, alongside entity-bound `async rule` execution — `future` (`await ` on a `Future`: asset loading, non-inlined `async fn` returns) is now the **sole** reserved await target. `override` stays the last reserved top-level construct keyword, and `quantize` stays reserved in `non_s3_keywords` (its beat/bar musical clock is a later Sequencer-adjacent milestone). | +| Next planned milestone | M1.0.16 — closes the next EBNF v0.6 execution gap (criterion C1.6). M1.0 (Etch ↔ ECS interpreter) is **21 sub-milestones** (M1.0.0–M1.0.20), **NOT complete**: M1.0.0–M1.0.15 closed; M1.0.16–M1.0.20 close the remaining execution gaps, Etch-closure tag at M1.0.20. Await-family partition: `future` (`await ` on a `Future`: asset loading, non-inlined `async fn` returns) is the **sole** reserved await target. `override` stays the last reserved top-level construct keyword, and `quantize` stays reserved in `non_s3_keywords` (its beat/bar musical clock is a later Sequencer-adjacent milestone). | ## Tags @@ -52,6 +52,7 @@ knowledge base — see § Quick links spec. | `v0.10.12-concurrency-algebra` | 2026-07-03 | M1.0.12 — Concurrency algebra (`race`/`sync`/`branch`/`spawn { }` + cancellation + `TaskHandle` await) | Closes the concurrency chapter of EBNF v0.6 (C1.6). `race`/`sync` graduate (`kw_race`/`kw_sync`); the four statements parse (`ConcurrencyBranch` shared slab + `RaceStmt`/`SyncStmt`/`BranchStmt`/`SpawnStmt`; `spawn (`/`spawn {` token disambiguation kept local; `let h = spawn { }` IS the SpawnStmt binding form, not a let-stmt). Type-check: E0901 on the four forms outside async; **`E0905 UnconsumedAsyncEffect`** — `await` is the SOLE call-grain consumer (§9.2 **revision 2**, STOP round-trip: the constructs relocate the suspension, their bodies are ordinary async contexts); **`E0906`** `return` race-branch-only (winner-return propagation); **`E0907`** break/continue must not cross the task boundary (per-branch loop depth + label window); `TaskHandle` builtin (non-POD, field-rejected), `h.cancel()`, `await h` typed (unit Phase 1). Execution: heap-record pointer-stable monotonic task pool (husk parking — no reuse, index = Phase-1 handle identity, no generations); drive-by-origin (children interleave at the origin rule's position, creation order, mid-pass pickup); `race`/`sync` = child task per admitted branch (guards evaluated first in the live parent scope; per-branch scope SNAPSHOT copy) with parent suspended on `children_any`/`children_all` (zero admitted → same-tick passthrough; one-tick construct latency otherwise — parent precedes children in pool order, so losers are canceled before their wakes can fire); race = first-`.done`-in-declaration-order winner, losers `cancelTask`'d (NON-transitive), winner-`return` re-raised at the race site; failed tasks park `.canceled` (never a winner, never block a join, `await` on them fails loud). `branch`/`spawn` = detached tasks (`Value.task_handle` = pool index); `await h` on done = immediate resume delivering the parked result, on canceled(-while-awaited) = fail-loud. Fix-as-you-go: `return await ` no longer drops its return at resume (M1.0.11 gap). | | `v0.10.13-time-and-timers` | 2026-07-04 | M1.0.13 — Time subsystem and timers | First builtin engine resources (`GameTime`/`UnscaledTime`/`RealTime`), auto-registered from a single `pub const` descriptor table in `types.zig` (the `TagSet` injection-point precedent; no `.etch` prelude) and known to the type-checker by name (`get(GameTime).dt`, ambient — no `when resource` gate). `stepOnce` populates them at the tick head (fixed 1/60 timestep): two internal f64 clock accumulators in TICK units (game `+= time_scale`, 0 under `paused`; unscaled `+= 1` — E5 recorded deviation vs the brief's seconds framing, accepted: exact integer f64 sums at `scale = 1` make the byte-identical wake guarantee robust), seconds derived at publication. `await wait` re-plumbed onto the game clock (freezes under `paused`, scales with `time_scale`), `await wait_unscaled` executes against the unscaled clock (fires under pause) — both keep the M1.0.11 literal-only restriction; byte-identical wake ticks at `scale = 1` (all M1.0.11/12 tick-precise tests untouched); `async_tick` demoted to the frame counter backing `GameTime.frame`. Timer family graduated + executes: `kw_every`/`kw_after_unscaled`, `TimerStmt` AST slab, `timer_stmt` parses (unbound at statement head + `let t = after(d) { }` in `parseLetStmt`, the spawn precedent), `TimerHandle` builtin type-checks (`cancel()` only, not awaitable, non-POD field-rejected), timer arg = full `Duration` expression, timer body = synchronous context (`await`/async call inside → E0901). Runtime timer registry: heap records, monotone pointer-stable + husk (a `TimerHandle` is a bare index), scope SNAPSHOT at scheduling (`cloneLocalsInto`, handle bound in the parent scope after), fired at the head of `stepOnce` in registration order (deterministic), one-shots husk / `every` re-arms at a fixed period (no drift correction), `cancel()` idempotent with a mid-fire latch. `Value` gains `timer_handle` + `duration` (runtime Duration seconds). `quantize` stays reserved (fail-loud re-pointed at a later Sequencer-adjacent milestone). | | `v0.10.14-entity-scoped-events` | 2026-07-05 | M1.0.14 — Entity-scoped events and entity-bound async rules | `await entity_event(e, T [{…}])` executes: designated `Entity` field resolved at compile time (`@entity_target` > single structural `Entity` field > `E0908`/`E0909`, single shared policy helper `resolveEventEntityTarget`), matched by name over the per-tick `EventStore` (predicate scan, no entity index; the matched event is NOT consumed — observers and other awaiters coexist). Payload filter (`struct_literal_body`) executes on BOTH event targets (`global_event` erratum §4.2): field-equality only, entity operand + every filter expression captured BY VALUE once at suspension (capture-once §9.4), resume delivers unit (no implicit `event` binding). Non-AST strings (`.string_run`, borrowed `.string_persistent`) deep-copied at the two escape boundaries (emit → store-owned per-tick; filter capture → husk-lifetime buffer) — fixes a pre-existing use-after-free for `@on_event` observers reading computed-string event fields. Entity-bound `async rule` executes (absorbed at the E2 round-trip): one root task per (rule, matched entity) via the SHARED sync selection walk (collect mode — identical order + `when` semantics), four rulings (§9.2): re-arm after terminal while matching; `when` gates the spawn, not the task's life; despawn does not cancel (dead-handle fail-loud on access); spawn follows selection order. Parameterless shape byte-stable; other param shapes stay fail-loud (`dt` injection later). `emit` enum-shorthand event fields fixed (fail-loud before). `future` is the sole remaining fail-loud await target. | +| `v0.10.15-test-runner` | 2026-07-05 | M1.0.15 — Etch test-runner + test identity | `test "X" { }` blocks execute end-to-end. Test names live in a SEPARATE symbol namespace (descriptive `STRING_LITERAL`, never code-referenceable): `test "Foo"` no longer collides with `component Foo`; two homonymous `test "X"` → `E0101` (contextual message, code reused — no new code minted for the duplicate). Test bodies type-check as SYNC contexts (`await` → existing `E0901`); `@tag`/`@skip`/`@only` annotations plumbed through `TestDecl` (parser stopped discarding them) + applicability via `validateAnnotations`. Runner library `src/etch/test_runner.zig` (public via `root.zig`): declaration order, FRESH World + Interpreter per test (`bindToWorld`), per-test failure conversion (name + message + span, NOT an aggregated `runtime_errors` count — §32), `@skip`/`@only`, per-test + aggregate reporting, report OWNS its strings (`a.dupe` before the next test — string discipline). Test-world surface: `world_handle` Value, `test_world()` (mono-world), `spawn_with([C {…}]) -> Entity` (IMMEDIATE spawn via the shared observer-firing primitive `world.spawnWithObservers`, factored out of the deferred `.spawn` flush — one spawn path), `emit(T {…})` method form (shared `enqueueEvent`), `tick(n)` (one-shot `suppress_event_clear` so a pre-tick `emit`/observer event survives into the first tick — §32 `emit; tick(1)`). Assertion family (`etch-stdlib.md §19.4`): `assert_eq`/`assert_neq` (byte-compare strings via `eventValueEql`; aggregates rejected at type-check), `assert_approx` (float-only operands, tol `1e-6`), `assert_some`/`assert_none`, `panic`/`todo`/`unreachable`. `measure { block }` expression (`E0910 MeasureOutsideTest` — sole wall-clock surface, test-body only, `std.Io.Clock`), `tick_until(pred: () -> bool, timeout: Duration) -> bool` (predicate re-evaluated each tick, `round(secs·60)` budget, a throwing predicate surfaces as the failure). A test body SUPPRESSES the per-body store resets of every rule/guard/timer/observer/hook body `tick(n)` drives (`suppress_body_store_resets`, single choke point `resetBodyStores`) — a driven body must not free heap-backed values the test's locals still hold (the M1.0.14 UAF class); freed en bloc at the test-body end. CLI shim `tools/etch_test/` (etch_cook pattern) + `zig build test-etch` (driver + shim over green fixtures, one invocation) wired into CI. New diagnostic: E0910. | ## Hypotheses validated by spikes @@ -89,6 +90,7 @@ knowledge base — see § Quick links spec. - **M1.0.12 scope boundary (concurrency algebra)** — the four constructs (`race`/`sync`/`branch`/`spawn { }`) parse, type-check, and execute as CHILD TASKS in the interpreter's pointer-stable monotonic pool (heap records, husk parking, no slot reuse — a pool index IS the Phase-1 `TaskHandle`, no generations), driven by-origin at the creating rule's position in creation order. **Recorded deviation (STOP round-trip 2026-07-02, `etch-resolver-types.md` §9.2 revision 2):** the brief's "calls inside the four construct bodies count as consumed launch sites" is SUPERSEDED — `await` is the SOLE call-grain consumer of the `{async}` effect (`E0905` applies recursively inside the construct bodies; the constructs relocate the suspension into a child task, they do not replace the `await`). Return asymmetry (Guy's ruling 2026-07-02): `return` legal only in a `race` branch (winner-return re-raised at the race site); `sync`/`branch`/`spawn` bodies reject it (`E0906`). Documented one-tick construct latency: a race/sync parent precedes its children in pool-creation order, so it resumes the tick AFTER its wake fires — which guarantees losers are canceled before their own wakes can fire (zero-admitted constructs have no latency). Cancellation is NON-transitive (Phase 1, `etch-bytecode.md` §9.5). Failed tasks (uncaught `throw` / runtime failure) park `.canceled` — never a race winner, never block a `sync` join, `await`ing them fails loud (§9.8 amended). Fix-as-you-go: `return await ` dropped its return at resume (M1.0.11 gap) — fixed. **Out (later):** `wait_unscaled` + timers (M1.0.13), `entity_event` (M1.0.14), entity-bound `async rule`, Phase-2 bytecode lowering (`etch-bytecode.md` §9.4), transitive cancellation (Phase 2+), task-pool slot reuse / generations (Phase-2 refcounted model), value-producing spawn bodies (no EBNF v0.6 value channel). - **M1.0.13 scope boundary (time subsystem and timers)** — the three builtin time resources (`GameTime`/`UnscaledTime`/`RealTime`) are auto-registered from the single `pub const builtin_resources` descriptor table in `types.zig` (the `TagSet` injection-point precedent — no `.etch` prelude, no separate module) and resolve AMBIENTLY (`get(GameTime).dt` needs no `when resource` clause — the spec examples read them without one). `await wait` re-plumbed onto scaled game time and `await wait_unscaled` onto unscaled time with **byte-identical wake ticks at `time_scale = 1`** — realized by the E5 recorded deviation: the two internal clock accumulators advance in TICK units (game `+= time_scale`, 0 under `paused`; unscaled `+= 1`; deadlines `clock + round(secs * 60)`), seconds derived only at resource publication (exact integer f64 sums at `scale = 1`; a seconds accumulator would accrue rounding that can shift a wake by one tick). Both waits KEEP the M1.0.11 literal-only Duration restriction; only the timer family evaluates a full `Duration` expression. The timer registry mirrors the M1.0.12 task-pool discipline (heap records, monotone pointer-stable, husk parking, `TimerHandle` = bare index, no generations) but is a DISTINCT mechanism — a timer is not a task: `cancel()` only, not awaitable, body is a synchronous context (E0901 on `await`/async calls inside). Timer callbacks fire at the head of `stepOnce` (after the clock advance and event-store clear, before rule dispatch) in registration order; `every` re-arms at a fixed period (no drift correction Phase 1). `Value` gained `timer_handle` + `duration` (`value.zig` — outside the brief's per-file list, justified: `Value.timer_handle` is the E6 deliverable and the `task_handle` precedent lives there). **Out (later, not debt):** `quantize` (stays reserved; needs the Sequencer/Pulse beat/bar clock — later Sequencer-adjacent milestone), `time.*` stdlib sugar (later stdlib milestone; `etch-stdlib.md §20` reconciliation deferred with it), `dt` as an injected rule parameter, per-entity `TimeDilation` / selective pause / `WorldClock` (need the phase scheduler), lifting `wait`'s literal-only restriction, Phase-2 bytecode lowering. - **M1.0.14 scope boundary (entity-scoped events + entity-bound async rules)** — `entity_event` = `global_event` + predicates (same wake path, same per-tick store, producer-before-awaiter order unchanged). Designated-field convention, filter semantics (equality-only, capture-once), and resume-unit are normative in `etch-reference-part1.md §9.4`; the four entity-bound lifecycle rulings in `§9.2`; realization in `§9.12` (Claude.ai KB re-uploads at open + at the E2 amendment). The M1.0.11/12 out-list mentions "entity-bound `async rule` → later" are **superseded** (delivered here — absorbed at the E2 round-trip: the construct is inexpressible end-to-end without an entity-scoped awaiter). String stabilization at the event/filter escape boundaries fixed a PRE-EXISTING observer-side use-after-free (fix-as-you-go, two STOP round-trips). **Out (later, NOT debt):** `future`/`Future` (the last await gap); implicit `event` binding / payload delivery at resume (unit is normative); entity-keyed event index (Phase-2 typed bus); emit-side targeting (grammar v0.6 frozen); non-equality filter predicates; cancel-on-despawn (Phase 2+); `dt`/non-entity rule-parameter injection; Phase-2 bytecode async lowering. +- **M1.0.15 scope boundary (test-runner + identity)** — `test "X" { }` executes end-to-end (parse+validate+register existed since M1.0.8; zero execution surface until now). **Resolves the M1.0.8 open residual** (a string-named `test "X"` sharing the identifier namespace via `registerSymbol` → `test "Foo"`/`component Foo` collision): test names now live in a SEPARATE `test_symbols` table; intra-namespace uniqueness reuses `E0101` with a contextual message (precedent: `collectImplMethods`, E1900 double-usage §24.1) — no new code minted for the duplicate. Semantics normative in `etch-reference-part2.md §32` (Phase-1 realization block, Claude.ai KB re-upload) + `etch-grammar.md §17` (`measure_expr` erratum). Realization: test bodies are SYNC (`await` → `E0901`); FRESH World per test (`bindToWorld`); `test_world()` is **mono-world** (repeated calls return the same handle — the multi-world shape is spec'd but not v0.6); `spawn_with` is an IMMEDIATE spawn (does not borrow the deferred structural-spawn command buffer — that is what lets it return a handle) reusing the shared observer-firing primitive; `measure` is the SOLE wall-clock surface, confined to test bodies (`E0910`) to keep gameplay deterministic. Runner is a library (`src/etch/test_runner.zig`) consumed in-proc by the `etch_test` shim + `zig build test-etch`; the future `weld test` (`engine-spec.md §26.1`) consumes the same library — no CLI-weld scaffolding minted for one subcommand. **Out (later, NOT debt):** multi-world (`test_world()` creating independent worlds); async test bodies (`await` stays `E0901`, spec'd v0.6 restriction — async exercised through rules + `tick(n)`); the `weld` CLI proper; test filtering flags (`--filter`, tag selection — `@only` covers focusing); GPU/session integration-test helpers (`create_test_session`, replay-driven runs — `engine-debug.md §13`, Phase 2+ module surface). **KB reconciliation done here (fix-as-you-go):** `etch-reference-part2.md §32` (dangling `engine-testing.md` ref removed → `engine-debug.md §13`); `engine-debug.md §13` realigned on §32 + grammar (`test_world`/`spawn_with`/`tick(n)`; the `capture_events` helper and a dedicated `integration_test` construct removed — never existed in EBNF v0.6); `etch-grammar.md §17` `measure_expr` erratum + `@tag(.perf)`; `engine-development-workflow.md §4.7` tag-scheme + multi-paragraph tag-message reconciliation. ## Non-negotiable rules diff --git a/briefs/M1.0.15-test-runner.md b/briefs/M1.0.15-test-runner.md new file mode 100644 index 00000000..28479e5d --- /dev/null +++ b/briefs/M1.0.15-test-runner.md @@ -0,0 +1,416 @@ +# M1.0.15 — Etch test-runner + test identity + +> **Status:** CLOSED +> **Phase:** 1.0 +> **Branch:** `phase-1/etch/test-runner` +> **Planned tag:** `v0.10.15-test-runner` +> **Dependencies:** M1.0.14 (`v0.10.14-entity-scoped-events`) +> **Opened:** 2026-07-05 +> **Closed:** 2026-07-05 + +--- + +# FROZEN SECTION + +*Produced by Claude.ai. Not modifiable by Claude Code outside a Claude.ai round-trip (cf. § Recorded deviations).* + +## Context + +M1.0.8 delivered `test "X" { block }` as parse + symbol registration only: `interp.zig` has zero execution surface for `test_decl`, test bodies are not type-checked, `@tag`/`@skip`/`@only` annotations are discarded at parse (`parseTestDecl`, `_ = annotations`), and a test named like a component collides in the shared top-level symbol map (E0101). This milestone makes `test` blocks executable end-to-end — separate test-name namespace, type-checked bodies, a runner library with a fresh World per test, the test-context builtins of `etch-grammar.md §17` (`test_world()`, `spawn_with`, `tick`, `measure`, `tick_until`, the §19.4 assertion family), and a CLI shim — closing the M1.0.15 line of the C1.6 gap plan and de-risking the acceptance tests of M1.0.16–M1.0.20, which will be written in Etch against this runner. + +## Scope + +- **Test identity (namespace separation).** A dedicated `test_symbols` table in the type-checker; `.test_` entries leave the main `symbols` map. `test "Foo"` no longer collides with `component Foo`. Intra-namespace uniqueness enforced: two `test "X"` in the same module → `E0101` with a contextualized message (`duplicate test 'X'` — same structural check, contextual message, per the E0101 precedent of `collectImplMethods` and the E1900 double-usage principle of `etch-diagnostics.md §24.1`). No new code minted for this. +- **Test body type-checking.** Test bodies are checked as sync statement contexts (they are currently registered but never checked). `await` inside a test body → existing `E0901` (non-async context). Test-scoped builtins (below) resolve only inside test bodies; outside, they fall through to existing `E0102 UndefinedSymbol`. +- **Annotation plumbing.** `TestDecl` gains the annotation range (parser stops discarding it). Applicability validated through the existing `validateAnnotations` machinery with a test target: only `@tag` / `@skip` / `@only` are legal on `test`; `@tag` argument validated against `{unit, integration, slow, perf}`; `@skip` takes `reason: string`. +- **Runner library** `src/etch/test_runner.zig`, public API exported via `root.zig`. Semantics (normative block: `etch-reference-part2.md §32`): iterate `test_decls` in declaration order; **fresh World per test** re-bound via the existing `bindToWorld` path (recon at E1: confirm exactly what `bindToWorld` re-registers — components/resources/tags — and close any gap so declarations are fully live in the fresh world); execute the body synchronously; convert failures (failed `assert*`, uncaught `throw`, runtime failure) into a per-test failure carrying test name + message + source span — not an aggregated `runtime_errors` count; apply `@skip` (reported skipped with reason, body not run) and `@only` (if ≥ 1 `@only` test exists in the compilation set, run only those; others reported skipped); produce a per-test report (pass/fail/skipped, wall-clock duration) plus an aggregate (`N passed / M failed / K skipped`). +- **Test-context builtins (world surface).** New world-handle `Value` kind. `test_world()` returns the handle of the current test's World — v0.6 realization is **mono-world**: repeated calls return the same handle. World methods: `spawn_with([C {…}, …]) -> Entity` — immediate spawn (does NOT go through the deferred structural-spawn command buffer; that is what allows returning a handle), firing `on_spawn` observers identically to the deferred path (reuse the same application primitive); argument is a component-literal list with bespoke checking (not a general heterogeneous array type). `emit(T {…})` — method form with the exact semantics of the `emit` statement (per-tick EventStore of the test world). `tick(n: int)` — advances the world by `n` ticks (`n` × `stepOnce`), `n ≥ 1`. +- **Assertion family builtins** (`etch-stdlib.md §19.4`), general-purpose (usable anywhere, not test-scoped; a failure outside a test remains a runtime failure as `assert` today): `assert_eq`, `assert_neq`, `assert_approx` (default tolerance `1e-6`), `assert_some`, `assert_none`; plus `panic`, `todo`, `unreachable` if the E1 recon finds them absent. Failure messages include both compared values where applicable. +- **`measure { block }` expression** (grammar erratum, `etch-grammar.md §17`): parser production + AST node + type-check (result type `Duration`; legal only inside a test body → **new `E0910 MeasureOutsideTest`**, effects range — measure is a wall-clock effect and wall-clock never enters deterministic gameplay code) + interpreter evaluation via monotonic wall-clock timer. +- **`tick_until(pred: () -> bool, timeout: Duration) -> bool`** test-scoped builtin: ticks the test world, evaluating `pred` after each tick, until `pred` is true or the tick budget derived from `timeout` is exhausted (`round(secs · 60)` ticks — same conversion as `wait`, M1.0.13). Returns whether the predicate was satisfied. +- **CLI shim** `tools/etch_test/` on the exact `etch_cook` pattern (arg parsing + I/O in the shim, all logic in the library): `etch_test […]`; each input file is its own compilation set (imports resolved as today); parse + type-check + run; prints per-test `✓` / `✗` / skipped lines with durations and the aggregate; exit code 0 iff zero failures and zero diagnostics. +- **Build integration**: `etch_test` executable + `zig build test-etch` step running the acceptance corpus; wired into CI. +- **Acceptance corpus** `tests/etch/test_runner/` (`.etch` fixtures + Zig driver, see Acceptance criteria). + +## Out of scope + +- **Multi-world** (`test_world()` creating additional/independent worlds). The mono-world realization is a documented v0.6 spec restriction (part2 §32 normative block), not parked debt. +- **Async test bodies.** `await` in a test body stays `E0901` — a spec'd v0.6 restriction. Async behavior is exercised through rules + `tick(n)`. +- **The `weld` CLI / `weld test` command.** Future consumer of this same library (`engine-spec.md §26.1`); no CLI-weld scaffolding in this milestone beyond the `etch_test` shim. +- **`capture_events` helper and a dedicated `integration_test` construct** — removed from the KB by the M1.0.15 spec reconciliation; do not implement. +- **Test filtering CLI flags** (`--filter`, tag selection). Purely additive; `@only` covers focusing in v0.6. +- **`measure` outside test bodies** — rejected by design (`E0910`). +- **GPU / session integration-test helpers** (`create_test_session`, replay-driven runs — `engine-debug.md §13`): Phase 2+ module surface. + +## Specs to read first + +1. `etch-grammar.md` — §17 (tests + `measure_expr` erratum), §4.1 (`assert_stmt`), §3.2 (closures, for `tick_until`) +2. `etch-reference-part2.md` — §32 in full, including the normative "Réalisation Phase 1 (M1.0.15)" block — this is the semantic contract of the milestone +3. `etch-stdlib.md` — §19.4 (assertion family, exact signatures and messages), §19.5 (test conventions) +4. `etch-diagnostics.md` — §4 (E01XX block), §12 (E09XX block), §24.1 (code-reuse precedents) +5. `engine-phase-1-plan.md` — M1.0 table (M1.0.15 row + ordering rationale) + +## Files to create or modify + +- `src/etch/types.zig` — modify — `test_symbols` table + intra-namespace E0101; test-body type-checking (sync context, test-scoped builtin resolution); annotation applicability for `test`; `E0910` check for `measure` +- `src/etch/parser.zig` — modify — `parseTestDecl` keeps the annotation range; `measure_expr` production +- `src/etch/ast.zig` — modify — `TestDecl` annotation range; `measure` expression node +- `src/etch/diagnostics.zig` — modify — `E0910 MeasureOutsideTest`; contextualized E0101 message path for duplicate tests +- `src/etch/value.zig` — modify — world-handle `Value` kind (recon: confirm `Duration` value representation for `measure` / `tick_until`) +- `src/etch/interp.zig` — modify — evaluation of `test_world` / `spawn_with` / `emit` (method form) / `tick` / `tick_until` / `measure` / assertion builtins; per-test failure conversion hooks +- `src/etch/test_runner.zig` — create — runner library (iteration, fresh-world binding, skip/only, reporting) +- `src/etch/root.zig` — modify — export the runner public API +- `tools/etch_test/main.zig` — create — thin CLI shim (etch_cook pattern) +- `build.zig` — modify — `etch_test` executable + `test-etch` step +- `tests/etch/test_runner/` — create — `.etch` fixtures + Zig driver test +- `.github/workflows/` (existing CI workflow file) — modify — run `zig build test-etch` +- `briefs/M1.0.15-test-runner.md` — create — this brief +- `CLAUDE.md` — modify — §3.4 update (patch content provided by Claude.ai at Step 4) + +## Acceptance criteria + +### Tests + +- `tests/etch/test_runner/driver_test.zig` — `test "passing test reports pass"` — a fixture with one green test yields 1 passed / 0 failed +- `tests/etch/test_runner/driver_test.zig` — `test "failing assertion reports name, message, and span"` — a fixture with a failing `assert_eq` yields 1 failed; the failure carries the test name, a message including both values, and the source position +- `tests/etch/test_runner/driver_test.zig` — `test "test name no longer collides with component name"` — fixture declaring `component Foo` + `test "Foo"` type-checks with zero diagnostics +- `tests/etch/test_runner/driver_test.zig` — `test "duplicate test names are E0101"` — two `test "X"` in one file → exactly one `E0101` +- `tests/etch/test_runner/driver_test.zig` — `test "skip and only semantics"` — `@skip` reported skipped with reason; presence of one `@only` runs only that test +- `tests/etch/test_runner/driver_test.zig` — `test "fresh world per test"` — a test spawning entities does not leak state into the next test (entity absent in test 2) +- `tests/etch/test_runner/driver_test.zig` — `test "spawn_with returns a live handle and fires observers"` — handle usable with `.get(C)` immediately; `on_spawn` observer effect visible +- `tests/etch/test_runner/driver_test.zig` — `test "tick drives rules and events"` — fixture: rule reacting to an emitted event mutates a component; `world.emit` + `world.tick(1)` + assertion on the mutated value passes +- `tests/etch/test_runner/driver_test.zig` — `test "await in test body is E0901"` — fixture with `await wait(1.0s)` in a test body → `E0901` +- `tests/etch/test_runner/driver_test.zig` — `test "measure outside test body is E0910"` — `measure { }` in a `fn` body → `E0910` +- `tests/etch/test_runner/driver_test.zig` — `test "measure returns a positive duration"` — `measure` around a loop yields `Duration > 0` +- `tests/etch/test_runner/driver_test.zig` — `test "tick_until stops on predicate and on timeout"` — both outcomes exercised (returns true early; returns false at budget exhaustion) +- Inline tests in `types.zig` / `parser.zig` for: annotation applicability on `test` (illegal annotation rejected; `@tag(.perf)` accepted), annotation range preserved on `TestDecl`, `measure_expr` parse shape +- Zero leaks under `std.testing.allocator` across the driver suite + +### Benchmarks + +- None (runner is orchestration; no perf gate this milestone). + +### Observable behavior + +- `zig build test-etch` runs the acceptance corpus and prints per-test `✓ ()` lines, skipped lines with reasons, and the `N passed / M failed / K skipped` aggregate. +- Running `etch_test` by hand on a deliberately failing fixture prints the `✗` line with message + `file:line` and exits non-zero. + +### CI + +- `zig build` clean, zero warnings, on the configured matrix +- `zig build test` green (debug + ReleaseSafe) +- `zig build test-etch` green +- `zig fmt --check` green +- `zig build lint` green +- `commit-msg` hook green on every commit of the branch + +## Conventions + +- **Branch:** `phase-1/etch/test-runner` +- **Final tag:** `v0.10.15-test-runner` +- **PR title:** `Phase 1 / Etch / Test-runner + test identity` +- **Commit convention:** Conventional Commits (cf. `engine-development-workflow.md §4.3`) +- **Merge strategy:** squash-and-merge (cf. `engine-development-workflow.md §4.6`) + +## Notes + +**Gate breakdown (E1–E5, gate-by-gate review protocol):** + +- **E1 — Recon + identity + annotations.** In-repo recon first (mandatory, findings journaled in the Execution log): (a) what `bindToWorld` re-registers exactly and what gap remains for a fully-live fresh world; (b) the builtin-function registration mechanism in the resolver (the assertion family and test-scoped builtins must ride it, not a parallel path); (c) `Duration` value representation and comparison support (`elapsed < 0.1s`); (d) whether `panic` / `todo` / `unreachable` builtins already exist; (e) entity-handle method evaluation (`.get(C)`) outside rule bodies. Then: `test_symbols` table + intra-namespace E0101 + annotation range on `TestDecl` + applicability + test-body type-checking (sync context). +- **E2 — Runner library.** Iteration, fresh World per test via `bindToWorld` (closing the E1-recon gap if any), sync execution, failure conversion (name + message + span), `@skip`/`@only`, per-test + aggregate reporting. +- **E3 — World surface.** World-handle `Value` kind, `test_world()`, `spawn_with` (immediate, observer-firing), `emit` method form, `tick(n)`. +- **E4 — Assertions + measure + tick_until.** §19.4 family, `measure_expr` (parser → AST → E0910 → eval), `tick_until` (closure predicate, tick budget). +- **E5 — Shim + build + acceptance.** `tools/etch_test`, `zig build test-etch`, CI wiring, the full acceptance corpus, docs. + +**Traps and constraints:** + +- **String discipline (M1.0.14 lesson).** Any `Value` escaping a body's lifetime into a new storage boundary (e.g., failure messages captured for the report, values formatted into assertion messages) must go through the `stringNeedsOwning` / deep-copy discipline. Assertion-failure messages must be owned by the report, not borrowed from interpreter scratch memory. +- **`spawn_with` immediacy.** Reuse the same application primitive as the command-buffer flush (the observer-firing path) — do not duplicate spawn logic. The handle must be valid immediately (same tick). +- **Test-scoped resolution.** `test_world` / `tick_until` resolve only inside test bodies (natural `E0102` outside). The assertion family is global stdlib. `measure` is grammatical, hence needs the explicit `E0910` context check in the type-checker. +- **Diagnostic codes.** `E0910` is the next free code in E09XX (E0901–E0909 + E0950 taken). Do not mint any other code; E0101 is reused with a contextual message only. +- **Compilation-set boundary.** `@only` scope is per compilation set (= per input file). Cross-file test selection is not a v0.6 concern. +- **Module rooting (Zig 0.16 lazy analysis).** `test_runner.zig` and `tools/etch_test` must be transitively reachable from their test/build roots or their inline tests silently skip (cf. `engine-zig-conventions.md §13`). +- **Determinism.** `measure` is the only wall-clock surface in Etch and stays confined to test bodies — do not expose wall-clock anywhere else. + +**Rejected alternatives (recorded):** implicit-world model without `test_world()` (would force rewriting every test when the spec'd explicit-world model lands — violates design-day-1); bootstrapping the `weld` CLI for a single subcommand (the shim + library split gives `weld test` a zero-refactor consumption path later); minting a dedicated duplicate-test diagnostic (E0101 reuse has two established precedents). + +--- + +# LIVING SECTION + +*Maintained by Claude Code during the milestone. The log is not a marketing report: it serves review and post-mortem debugging.* + +## Specs read + +*Check before writing any production code. Confirms the spec was ingested in full, not merely skimmed.* + +- [x] `etch-grammar.md` (§17, §4.1, §3.2) — read 2026-07-05 13:07 +- [x] `etch-reference-part2.md` (§32) — read 2026-07-05 13:09 +- [x] `etch-stdlib.md` (§19.4, §19.5) — read 2026-07-05 13:11 +- [x] `etch-diagnostics.md` (§4, §12, §24.1) — read 2026-07-05 13:05 +- [x] `engine-phase-1-plan.md` (M1.0 table) — read 2026-07-05 13:05 + +## Execution log + +- 2026-07-05 13:20 — E1 recon (mandatory 5 points, findings before any code): + - **(a) `bindToWorld`** (`interp.zig:1627`) registers ONLY observer rules + + the `on_attach`/`on_detach` hook seams. Full type registration — + components/resources (`compileComponent`/`compileResource` → + `registerComponentRaw`), builtin `TagSet`, the three builtin time resources, + rules, fns, methods, structs, enums, and the event-decl index — all happens + in `Interpreter.compile(gpa, ast, world)` (`interp.zig:1268`). Events are + AST-indexed (`event_decls`), no world RTTI. → Fresh-world-per-test = + per test `World.init()` + `Interpreter.compile(ast, &fresh)` + `bindToWorld`; + a fresh compile re-registers everything, no residual gap. + - **(b) builtin-fn mechanism** — NONE exists. `synthCall` (`types.zig:5311`) + resolves a free call only to a user top-level `fn` (`synthFreeFnCall`); a + non-fn callee must be a closure. `assert` is a STATEMENT (`assert_stmt`, + interp `4023`), not a fn. The assertion FAMILY (`assert_eq`/`assert_neq`/ + `assert_approx`/`assert_some`/`assert_none`) and `panic`/`todo`/`unreachable` + do not exist. → Add ONE builtin-fn path: type side in `synthCall`, eval side + in the `fn_call` arm; global asserts unconditional, test-scoped + (`test_world`/`tick_until`) gated to test-body context (natural E0102 else). + - **(c) `Duration` repr + comparison** — `Value.duration: f64` (seconds, + M1.0.13) + `duration_lit`→`Value{.duration}` (interp `4972`) + + `BuiltinType.duration` all exist. BUT `binaryCompare` (interp `5687`) covers + only `.int_`/`.float_`/`.bool_` — NO `.duration` ordering; type-checker does + not type `Duration < Duration`. → E4 adds Duration ordering (interp + checker) + for `elapsed < 0.1s` / `Duration > 0`. No world-handle `Value` kind exists → + add in E3 (mono-world: trivial payload; `execStmt` already threads `world`). + - **(d) `panic`/`todo`/`unreachable`** — absent; `assert` statement present + (reused for failure conversion). → add the three as builtins in E4 on the + (b) mechanism. + - **(e) entity method eval outside rule bodies** — `dispatchMethodOnValue` + (`interp.zig:4597`) dispatches on the runtime receiver `Value` regardless of + context; `entity.get(C)` resolves via `Bridge.componentRefOf` (`interp.zig: + 5082`). A test body run through the shared `execStmt`/`evalExpr` machinery + with a bound `world` calls `entity.get(C)` freely — NO gating gap. + - Support facts: annotation range pattern `annotations_extra`/`annotations_len` + (`ast.zig`, `RuleDecl`); `validateAnnotations`/`annotationAppliesTo` + (`types.zig:339`,`:3464`) with `AnnotTarget` enum (add `.test_`); `@tag`/ + `@skip`/`@only` are NOT in `AnnotationKind` (map to `.custom`, applies-to-all) + → add real kinds. `parseTestDecl` (`parser.zig:854`) DISCARDS the annotation + range. Resolver `pass1Collect` registers `.test_` into the shared `symbols` + map (`types.zig:2933`) → move to `test_symbols`. `runProgram` (`interp.zig: + 1236`) + `spawnDynamicWithValues`/`dispatchOnSpawned`/`applyWithObservers` + (`observers.zig`) are the reuse points for E2/E3. +- 2026-07-05 13:45 — E1 done (identity + annotations + test-body type-check): + - `ast.zig`: `TestDecl` gains `annotations_extra`/`annotations_len`; + `AnnotationKind` gains `tag`/`skip`/`only` (+ `fromName`). + - `parser.zig`: `parseTestDecl` preserves the annotation range (was discarded). + - `types.zig`: dedicated `test_symbols` namespace (test name no longer collides + with a same-named `component`/etc.); intra-namespace uniqueness → E0101 with + contextual "duplicate test 'X'". `AnnotTarget` += `.test_`; + `annotationAppliesTo` maps `.tag`/`.skip`/`.only` → test-only. + `checkTestAnnotations` validates args (`@tag` ∈ {unit,integration,slow,perf}, + `@skip(reason: string)`, `@only` no-arg) reusing E0502 (no new code). + `checkTest` type-checks the body as a SYNC context (`in_test_body` flag set; + `await`→E0901) — wired into `pass2Resolve`. + - Tests: types.zig — duplicate-test E0101, no-collision-with-component, + annotation applicability+args, await→E0901; parser.zig — annotation range + preserved on `TestDecl`. Full `zig build test` green (EXIT 0), `zig fmt + --check` + `zig build lint` clean. No new diagnostic code (E0910 is E4). +- 2026-07-05 14:20 — E2 done (runner library + failure conversion): + - `test_runner.zig` (new): `run(gpa, ast) -> RunReport`. Iterates `test_decls` + in declaration order; fresh `World` + `Interpreter.compile` + `bindToWorld` + per test (full isolation — E1 recon confirmed compile re-registers all + decls); `@skip` (reason extracted, body not run) + `@only` (per compilation + set: if any, only those run); per-test `TestResult` (name/status/duration/ + message/span) + aggregate passed/failed/skipped. Report owns its strings + (internal arena) — messages copied out before the next test (§32 trap). + - `interp.zig`: `runTestBody(world, decl) -> TestBodyOutcome{pass|fail}` + (mirror of `execTimerBody`, sync run-to-completion, param-less locals); + failure = failed `assert`/uncaught `throw`/runtime failure → span + message, + NOT an aggregated count (§32). `pending_message` + owned `test_msg_buf` + (E4-ready for formatted assert messages). `assert` statement now raises + `fail(.AssertFailed, cond_span)` carrying its literal message (strict + improvement: rule-body asserts also get a span). + - `value.zig`: `RuntimeErrorKind.AssertFailed` (runtime kind, not a diagnostic + code). + - `root.zig`: export `test_runner`/`RunReport`/`TestResult`/`TestStatus` + (module rooting + public API for the E5 shim / future `weld test`). + - Tests: 5 inline in `test_runner.zig` (pass, fail w/ name+message+span, skip + w/ reason, @only focus, mixed aggregate) using the plain `assert` statement + (world surface + assert family land E3/E4). + - Nit absorbed (Guy, GO E1): removed dead `SymbolKind.test_` variant; fixed the + stale "registered intra-module" `buildExports` comment (`root.zig`). + - `zig build test` green (EXIT 0), `zig fmt --check` + `zig build lint` clean. +- 2026-07-05 15:30 — E3 done (test-world surface) + two latent-defect fixes the + E3 wire-in exposed: + - **[latent, important] `test_runner.zig` inline tests were NOT running** at the + E1/E2 gates: a `pub const` re-export pulls declarations, not `test` blocks + (Zig 0.16 lazy analysis, `engine-zig-conventions.md` §13). Added + `_ = @import("test_runner.zig")` to root.zig's `comptime` wire-in guard — the + E2 tests (and E3's) now actually execute. Verified by a break-test. + - **[latent] `std.time.Timer` does not exist in this Zig 0.16** (`std.time` has + only constants; clocks live under `std.Io.Clock`). It compiled at E2 only + because `run()` was lazily un-analyzed. Fixed: `run(gpa, io: std.Io, ast)` + threads the process `io`; per-test duration via `Io.Clock.now(.awake, io)`; + inline tests + the E5 shim supply an `std.Io.Threaded`. + - `value.zig`: `Value.world_handle` (void, mono-world). `types.zig`: + `ResolvedType.test_world`; `test_world()` builtin in `synthCall` (test-body + gated, else E0102); `dispatchMethodOnType` arm for `spawn_with` + (`checkSpawnWithArg` reuses `checkStructuralComponentLiteral`), `emit` + (`checkWorldEmitArg` reuses `checkEventFieldRun`), `tick` (int arg); `checkTest` + now `unrestricted_ecs_access` (test bodies read any resource/component). + - `interp.zig`: `test_world()` -> `Value.world_handle`; `dispatchMethodOnValue` + world arm; `evalSpawnWith` (immediate, returns Entity), `evalWorldEmit`, + `evalWorldTick`; `enqueueEvent` extracted from the `emit` statement + shared. + **Event-clear fix for §32 `emit; tick(1)`**: `stepOnce` clears the event store + at its head, which would drop a pre-tick `world.emit`; `evalWorldTick` sets a + one-shot `suppress_event_clear` when events are pending so the first tick sees + them, and clears the store at the end (no lingering). + - `parser.zig`: `.kw_emit` postfix arm — `world.emit(...)` (`emit` is a keyword, + so it was not caught by the `.ident` method arm; routed to `method_call`). + - **Tier-0 touch (justified by the §32 "reuse the same application primitive" + trap)**: `observers.zig` factors `spawnWithObservers` (returns the eid, fires + on_spawned + on_add) out of `applyWithObservers`'s `.spawn` arm; `world.zig` + adds the `spawnWithObservers` wrapper (mirror of `dispatchOnSpawned`). No + behavior change to the deferred flush. + - Observer/event gating (pre-existing): observer / `@on_event` bodies have NO + unrestricted ECS access, so the E3 fixtures use the sanctioned patterns + (`@on_event ... when resource X`; observers emit; iterative rules for entity + mutation). + - Tests: 5 E3 inline (spawn_with live handle; spawn_with fires observers; + tick drives an iterative rule; emit+tick drives an @on_event rule; fresh-world + isolation across two tests). Full `zig build test` green (EXIT 0), `zig fmt + --check` + `zig build lint` clean. +- 2026-07-05 16:40 — E3 review STOP fix (latent test-body UAF): the E3 caveat + understated a real defect — a `test` body is the first body that OUTLIVES the + execution of other bodies, so any heap-backed value its locals hold before + `tick(n)` (a collection/struct/closure handle, or a `.string_run` from concat — + the M1.0.14 UAF class) was freed by the rule/guard/timer/observer/hook bodies' + per-body store resets, then read stale/OOB. Fix: single choke point + `resetBodyStores()` gated by a new `suppress_body_store_resets` flag that + `runTestBody` holds for the whole body — the 5 internal reset sites + (`runObserverBody`, `execHookText`, `resetGuardStores`, `execBody`) route + through it and no-op during a test; the driven bodies accumulate into the + shared stores (test-scale, bounded) and `runTestBody`'s own raw end-defers free + everything en bloc. Production paths never set the flag → byte-identical. + Caveat retired (doc comment of `evalSpawnWith` + journal). Two regression tests + added (collection held across `tick(1)`; `.string_run` held across `tick(1)`) — + both ABORT under testing.allocator with the guard neutered, pass with it (verified). + `zig build test` / `zig fmt --check` / `zig build lint` green. +- 2026-07-05 17:40 — E4 done (assertion family + measure + tick_until): + - Duration ordering: `binaryCompare` gains a `.duration` arm (the type-checker + already types `Duration Duration` as bool via the matching-builtin arm). + - `measure { block }` expression: `kw_measure` token, `ExprKind.measure_expr` + + `MeasureExpr` slab + `addMeasureExpr`, `parseMeasureExpr` (reuses the block-body + parser), `synthMeasure` (result Duration; E0910 `MeasureOutsideTest` outside a + test), interp eval via `std.Io.Clock` (`interp.io`, set by the runner). New + diagnostic `E0910`. + - Builtin free-fns via `synthBuiltinCall` (types) + `evalBuiltinCall` (interp), + folding the E3 `test_world`: assertion family (`assert_eq`/`neq`/`approx` (tol + 1e-6)/`some`/`none`, §19.4) + `panic`/`todo`/`unreachable` (global), + + test-scoped `tick_until(pred: () -> bool, timeout: Duration) -> bool` + (budget `round(secs·60)`, predicate after each tick). Assert messages format + both values into `test_msg_buf` (append-based; the ArrayList has no `writer` + in this Zig). + - Tests: E4 inline in test_runner.zig (assert family incl. both-values message; + panic/todo/unreachable; measure > 0; tick_until true+timeout), types.zig + (measure E0910), parser.zig (measure parse shape). + - **Adversarial review (Workflow) caught 5 CONFIRMED defects — all fixed:** + (1) stale assert message: `harvestError` now nulls `pending_message` too, so a + rule-body assert failure harvested during `tick(n)` cannot mis-label a later + `fail()`-routed test-body failure. (2) `test_world`/`tick_until` were + intercepted at the interp UNCONDITIONALLY (divergence from the checker's + `in_test_body` gate) → added an `in_test_body` runtime flag, gating them so a + user fn of that name outside a test resolves to the user fn. (3) `assert_eq`/ + `neq` were string-blind (`Value.eql`: `.string_run` always unequal, literal ≠ + persistent) → false-fail + dangerous false-PASS; now compare via + `eventValueEql` (byte-aware). (4) `assert_eq`/`neq` on aggregates + (struct/array/map/set/optional/closure) always mis-compared → type-check now + rejects non-comparable operands (`assertComparable`). (5) a throwing + `tick_until` predicate was swallowed and kept ticking → the loop now surfaces a + predicate throw/control immediately. Five regression tests added (one per fix). + - `zig build test` green (EXIT 0), `zig fmt --check` + `zig build lint` clean. +- 2026-07-05 18:10 — E4 review STOP fix (assert family constraint symmetry): + `assert_approx` did not constrain its operands to float at type-check (only + synth'd them), so `assert_approx(1, 2)` type-checked clean then failed at + runtime with a generic RuntimeFailure — asymmetric with the rest of the family + (assert_eq rejects aggregates + requires same type, assert_some/none require an + optional, all fail-loud at check). Fix: `synthBuiltinCall` assert_approx arm now + constrains `a`, `b`, and the optional tolerance to `.float_` (helper + `isFloatType`), emitting `.type_mismatch` otherwise; the interp's float check + stays as a defensive belt. +1 test (`assert_approx(1, 2)` → type_mismatch; the + float case stays clean). `zig build test` / fmt / lint green. +- 2026-07-05 19:15 — E5 done (shim + build + acceptance corpus + CI): + - `tools/etch_test/main.zig` — thin CLI shim on the `etch_cook` pattern (arg + parsing + file I/O + report printing only; logic in `weld_etch.test_runner`). + `etch_test …`: each file its own compilation set → parse → + type-check (print `[E….]` diagnostics + skip run on any) → run; prints + `✓ ()` / `✗ (:)` / `- (skipped: + )` + per-file/total aggregates; clean nonzero exit (`std.process.exit`) + on any diagnostic/failure. + - `build.zig` — `etch_test` executable + a `test-etch` step: runs the Zig + acceptance driver AND the shim over the green fixtures in ONE invocation + (clean, un-interleaved ✓ output, exit 0). The driver also joins `zig build + test`. `.github/workflows/ci.yml` runs `zig build test-etch` on every leg. + - Acceptance corpus `tests/etch/test_runner/`: `.etch` fixtures (green, failing, + only, isolation, world, timing) `@embedFile`d + inline diagnostic cases in + `driver_test.zig` — the 13 acceptance tests (passing/failing+span/collision/ + duplicate-E0101/skip+only/fresh-world/spawn_with+observers/tick+events/ + await-E0901/measure-E0910/measure-positive/tick_until), all under + `std.testing.allocator` (zero-leak). Verified `zig build test-etch` prints the + ✓/skip lines + `total: 10 passed / 0 failed / 2 skipped` (exit 0); `etch_test + failing.etch` by hand prints the ✗ line + `file:10` + exit 1. + - `zig build` / `zig build test` / `zig build test-etch` / `zig fmt --check` / + `zig build lint` all green. + +## Recorded deviations + +- (E3 review, 2026-07-05) — Added `suppress_body_store_resets` (a new interpreter + mechanism not in the brief's file-level plan) after the E3 STOP: it is the + minimal correct fix for the test-body-outlives-driven-bodies UAF. Confined to + `interp.zig`; no production-path behavior change (flag inactive outside a test + body). Ack'd by Guy's STOP verdict fix list. + +## Blockers encountered + +- None. Two STOP round-trips (E3 test-body UAF, E4 assert_approx constraint) were + review verdicts with a specific fix each, not framing failures — resolved in the + same gate (E3 fix `237fd67`; E4 fix `81d47d4`). + +## Closing notes + +- **What worked:** + - The E1 recon's five points held exactly — `compile()` is the full type + registration point, so "fresh World + Interpreter per test" fell out cleanly; + no gap in `bindToWorld`. + - Reusing existing primitives paid off: `spawn_with` shares the observer-firing + spawn path (`spawnWithObservers` factored from the deferred `.spawn` flush); + `emit` method shares `enqueueEvent`; assert string comparison reuses + `eventValueEql`; E0101 reused for duplicate tests (no new code). + - Adversarial-review workflows earned their cost: caught 5 real E4 defects + (incl. a dangerous `assert_neq` false-PASS on strings) before they shipped. + - Break-tests (deliberately neutering a fix / assertion, confirming the failure, + reverting) proved both the UAF fix and that the new inline/driver tests + actually execute — after the §13 wire-in gap surfaced. +- **What deviated from the original spec / brief plan:** + - Two mechanisms not in the brief's file-level plan, both minimal + confined + and ack'd by Guy's STOP fixes: `suppress_body_store_resets` (E3 UAF) and + `in_test_body` runtime flag (E4 test-scoped-builtin gate). Recorded above. + - A Tier-0 touch (`observers.zig`/`world.zig` `spawnWithObservers`) required by + the brief's own "reuse the same application primitive" trap — one spawn path, + no flush behavior change. + - `test-etch` runs the shim over the green fixtures in ONE invocation (clean + un-interleaved ✓ output) rather than one Run step per fixture. +- **What to flag explicitly in review:** + - The event-clear semantics for `emit; tick(1)`: `stepOnce` clears the event + store at its head, so `tick`/`tick_until` set a one-shot `suppress_event_clear` + to let a pre-tick event reach the first tick (§32). Non-test paths never set it. + - `assert_eq`/`assert_neq` are intentionally MORE capable than `==` (which + rejects strings at type-check): they compare strings by bytes and reject + aggregates — a test-only Eq surface, so `assert_neq` cannot silently false-PASS. + - The `hunt:semantics` adversarial agent stalled (never returned); the other + three lenses + inline/driver tests cover measure/tick_until/Duration/asserts. +- **Final measurements:** no perf gate this milestone (runner is orchestration). + `zig build test` green debug + ReleaseSafe; `zig build test-etch` prints + `total: 10 passed / 0 failed / 2 skipped` (exit 0); `measure` around a 5000-iter + interp loop reports ~5 ms wall-clock. New diagnostic: E0910 (sole new code). +- **Residual risks / tech debt left intentionally:** + - Memory during a long `tick(n)`/`tick_until` inside a test grows (per-body + store resets suppressed) — bounded by the test body's end (freed en bloc); + acceptable, test-scale (Guy-ack'd at the E3 STOP). + - `test_world()` is mono-world (spec'd v0.6 restriction, not debt); multi-world, + async test bodies, the `weld` CLI proper, and filtering flags are out per §32. diff --git a/build.zig b/build.zig index 1e2335f0..a1b81239 100644 --- a/build.zig +++ b/build.zig @@ -1127,6 +1127,56 @@ pub fn build(b: *std.Build) void { }); b.installArtifact(etch_cook_exe); + // M1.0.15 — `etch_test` shim (thin CLI over `weld_etch.test_runner`) + the + // `test-etch` acceptance step. The shim owns arg parsing + I/O; all logic is + // in the library (same split as `etch_cook`). + const etch_test_module = b.createModule(.{ + .root_source_file = b.path("tools/etch_test/main.zig"), + .target = b.graph.host, + .optimize = optimize, + }); + etch_test_module.addImport("weld_etch", etch_module); + etch_test_module.addImport("weld_core", core_module); + const etch_test_exe = b.addExecutable(.{ + .name = "etch_test", + .root_module = etch_test_module, + }); + b.installArtifact(etch_test_exe); + + // The Zig acceptance driver (assertions over the `.etch` fixtures + the + // diagnostic cases). Wired manually — not through `test_specs` — so the + // `test-etch` step can depend on it directly AND it stays in `zig build test`. + const etch_driver_module = b.createModule(.{ + .root_source_file = b.path("tests/etch/test_runner/driver_test.zig"), + .target = target, + .optimize = optimize, + }); + etch_driver_module.addImport("weld_core", core_module); + etch_driver_module.addImport("weld_etch", etch_module); + const etch_driver_test = b.addTest(.{ .root_module = etch_driver_module }); + const etch_driver_run = b.addRunArtifact(etch_driver_test); + test_step.dependOn(&etch_driver_run.step); + + const test_etch_step = b.step("test-etch", "Run the M1.0.15 Etch test-runner acceptance corpus (driver + shim over the green fixtures)"); + test_etch_step.dependOn(&etch_driver_run.step); + // Run the shim over the all-green fixtures in a SINGLE invocation (the shim + // takes many files): prints the ✓ / skipped lines + per-file and total + // aggregates (observable behavior), exits 0. One process → clean, un- + // interleaved output. `failing.etch` is exercised by the driver (which + // asserts the failure) + by hand, not here. + const etch_shim_run = b.addRunArtifact(etch_test_exe); + for ([_][]const u8{ + "tests/etch/test_runner/green.etch", + "tests/etch/test_runner/only.etch", + "tests/etch/test_runner/isolation.etch", + "tests/etch/test_runner/world.etch", + "tests/etch/test_runner/timing.etch", + }) |fixture| { + etch_shim_run.addFileArg(b.path(fixture)); + } + etch_shim_run.stdio = .inherit; // show the ✓ lines during the build + test_etch_step.dependOn(&etch_shim_run.step); + // Cook the 20 differential corpus programs into a single consolidated // `corpus_codegen.zig`. The driver test imports it via the // `corpus_codegen` module name. diff --git a/src/core/ecs/observers.zig b/src/core/ecs/observers.zig index 5bd14215..0ef21653 100644 --- a/src/core/ecs/observers.zig +++ b/src/core/ecs/observers.zig @@ -226,6 +226,33 @@ pub const ObserverRegistry = struct { try self.fireList(self.on_spawned, world, eid, null, null, null); } + /// Spawn an entity with initial component values AND fire the exact + /// observers a deferred `.spawn` flush fires — `on_spawned`, then + /// `on_add[cid]` per component — returning the new handle. Factored out of + /// `applyWithObservers`'s `.spawn` arm so an IMMEDIATE spawn that must return + /// a handle (the Etch `world.spawn_with` test-runner surface, M1.0.15) shares + /// the one observer-firing spawn path instead of duplicating it. The handle + /// is valid on return (same tick). Observer-issued structural changes queue + /// into the shared `deferred` buffer (drained at the next flush / tick). + pub fn spawnWithObservers( + self: *ObserverRegistry, + gpa: std.mem.Allocator, + world: *World, + component_ids: []const ComponentId, + payloads: []const []const u8, + ) !EntityId { + self.ensureDeferred(gpa, world); + const eid = try world.spawnDynamicWithValues(gpa, component_ids, payloads); + try self.fireList(self.on_spawned, world, eid, null, null, null); + for (component_ids) |cid| { + if (self.on_add.get(cid)) |list| { + const new_ptr: ?*const anyopaque = if (world.componentBytes(eid, cid)) |b| @ptrCast(b.ptr) else null; + try self.fireList(list, world, eid, cid, null, new_ptr); + } + } + return eid; + } + fn fireList( self: *ObserverRegistry, list: Listeners, @@ -294,15 +321,10 @@ pub fn applyWithObservers( ) !void { switch (c) { .spawn => |s| { - const eid = try world.spawnDynamicWithValues(gpa, s.component_ids, s.payloads); - try reg.fireList(reg.on_spawned, world, eid, null, null, null); - for (s.component_ids) |cid| { - if (reg.on_add.get(cid)) |list| { - // Post-apply: the new component value lives in storage. - const new_ptr: ?*const anyopaque = if (world.componentBytes(eid, cid)) |b| @ptrCast(b.ptr) else null; - try reg.fireList(list, world, eid, cid, null, new_ptr); - } - } + // Shares the returning-eid primitive with the immediate + // `world.spawn_with` surface (M1.0.15) — one observer-firing spawn + // path (on_spawned + on_add per component). + _ = try reg.spawnWithObservers(gpa, world, s.component_ids, s.payloads); }, .despawn => |d| { // Pre-apply: fire on_remove[cid] for every component the diff --git a/src/core/ecs/world.zig b/src/core/ecs/world.zig index 63b07a59..89a9aa06 100644 --- a/src/core/ecs/world.zig +++ b/src/core/ecs/world.zig @@ -329,6 +329,19 @@ pub const World = struct { try self.observer_registry.dispatchOnSpawned(gpa, self, eid); } + /// M1.0.15 — immediate spawn with initial values that fires the same + /// observers a deferred `.spawn` flush would (on_spawned + on_add), returning + /// the new handle. Backs the Etch `world.spawn_with` test-runner surface; + /// wraps `ObserverRegistry.spawnWithObservers`. + pub fn spawnWithObservers( + self: *World, + gpa: std.mem.Allocator, + component_ids: []const ComponentId, + payloads: []const []const u8, + ) !EntityId { + return self.observer_registry.spawnWithObservers(gpa, self, component_ids, payloads); + } + /// M1.0.6 E6 — register the `on_attach` extension dispatch callback (the Etch /// bridge supplies the real one; M1.0.6 tests supply a Tier-0 stand-in). One /// hook per world (last registration wins). diff --git a/src/etch/ast.zig b/src/etch/ast.zig index 79a8d66b..c2992fcf 100644 --- a/src/etch/ast.zig +++ b/src/etch/ast.zig @@ -266,6 +266,11 @@ pub const ExprKind = enum { /// `StmtKind.spawn_stmt` since M1.0.12) — the keyword is shared, the token /// after `spawn` disambiguates; this node is only ever the `(` form. spawn_struct, + /// `measure { block }` (M1.0.15, §17 erratum) — a wall-clock timing + /// expression yielding a `Duration`. Data indexes `measure_exprs`. Valid only + /// in a test body (E0910 elsewhere); determinism is preserved by confining + /// the sole wall-clock surface to tests. + measure_expr, }; /// Closed enum of type-node kinds the parser can produce. @@ -668,11 +673,14 @@ pub const ConstDecl = struct { /// Side-slab entry for a top-level `test` block (M1.0.8, `etch-grammar.md` /// §17: `test_decl = "test" STRING_LITERAL block`). `name` is the interned /// string-literal label; `body` is a `block_expr` NodeId (the reused -/// block/statement parser). M1.0.8 delivers parse + validate + symbol -/// registration only — there is no execution surface (that is M1.0.9). +/// block/statement parser). M1.0.15 delivers execution: the body is +/// type-checked (sync context) and run by `test_runner.zig`. The annotation +/// range (`@tag`/`@skip`/`@only`) is preserved (M1.0.15 — M1.0.8 discarded it). pub const TestDecl = struct { name: StringId, body: NodeId, + annotations_extra: u32 = 0, + annotations_len: u32 = 0, }; /// Side-slab entry for a `rule` declaration: params, optional `when` @@ -939,6 +947,16 @@ pub const BlockExpr = struct { value: NodeId, }; +/// `measure { block }` expression (M1.0.15, `etch-grammar.md` §17 erratum). Same +/// storage shape as a `BlockExpr` (statement run + optional trailing value); a +/// distinct node kind so the type-checker gates it (Duration result, test-body +/// only → E0910) and the interpreter times the block on the wall clock. +pub const MeasureExpr = struct { + body_start: u32, + body_len: u32, + value: NodeId, +}; + /// `if cond block {else if cond block} [else block]` if expression (M0.8 /// control flow, `etch-grammar.md` §3.2 l.500 / §4.1 l.618). The else-if chain /// is encoded recursively: `else_branch` is `NodeId.none` (no `else`), a @@ -2363,6 +2381,12 @@ pub const AnnotationKind = enum { // `await entity_event(e, T)` (§18.10). Field-level, event-only, // Entity-typed, at most one per event (validated in the type-checker). entity_target, + // M1.0.15 — `test`-only annotations (§17): `@tag(.unit|.integration|.slow| + // .perf)`, `@skip(reason: "...")`, `@only`. Applicability is `.test_` only + // (annotationAppliesTo); args are validated in the test-decl check. + tag, + skip, + only, pub fn fromName(name: []const u8) AnnotationKind { if (std.mem.eql(u8, name, "phase")) return .phase; @@ -2391,6 +2415,9 @@ pub const AnnotationKind = enum { if (std.mem.eql(u8, name, "on_spawned")) return .on_spawned; if (std.mem.eql(u8, name, "on_despawned")) return .on_despawned; if (std.mem.eql(u8, name, "entity_target")) return .entity_target; + if (std.mem.eql(u8, name, "tag")) return .tag; + if (std.mem.eql(u8, name, "skip")) return .skip; + if (std.mem.eql(u8, name, "only")) return .only; return .custom; } @@ -2607,6 +2634,7 @@ pub const AstArena = struct { loop_exprs: std.ArrayListUnmanaged(LoopExpr) = .empty, string_interps: std.ArrayListUnmanaged(StringInterp) = .empty, block_exprs: std.ArrayListUnmanaged(BlockExpr) = .empty, + measure_exprs: std.ArrayListUnmanaged(MeasureExpr) = .empty, if_exprs: std.ArrayListUnmanaged(IfExpr) = .empty, break_stmts: std.ArrayListUnmanaged(BreakStmt) = .empty, throw_stmts: std.ArrayListUnmanaged(ThrowStmt) = .empty, @@ -2832,6 +2860,7 @@ pub const AstArena = struct { self.loop_exprs.deinit(gpa); self.string_interps.deinit(gpa); self.block_exprs.deinit(gpa); + self.measure_exprs.deinit(gpa); self.if_exprs.deinit(gpa); self.break_stmts.deinit(gpa); self.throw_stmts.deinit(gpa); @@ -3548,6 +3577,12 @@ pub const AstArena = struct { return try self.addExpr(gpa, .block_expr, idx, span); } + pub fn addMeasureExpr(self: *AstArena, gpa: std.mem.Allocator, body_start: u32, body_len: u32, value: NodeId, span: SourceSpan) !NodeId { + const idx: u32 = @intCast(self.measure_exprs.items.len); + try self.measure_exprs.append(gpa, .{ .body_start = body_start, .body_len = body_len, .value = value }); + return try self.addExpr(gpa, .measure_expr, idx, span); + } + pub fn addIfExpr(self: *AstArena, gpa: std.mem.Allocator, cond: NodeId, then_block: NodeId, else_branch: NodeId, let_binding: StringId, span: SourceSpan) !NodeId { const idx: u32 = @intCast(self.if_exprs.items.len); try self.if_exprs.append(gpa, .{ .cond = cond, .then_block = then_block, .else_branch = else_branch, .let_binding = let_binding }); diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index b4a1baa4..334fb0dc 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -368,6 +368,7 @@ pub const DiagnosticCode = enum { control_flow_escapes_task_branch, // M1.0.12 E3 — E0907 ControlFlowEscapesTaskBranch (break/continue targeting a loop outside the concurrency construct) event_not_entity_scoped, // M1.0.14 E2 — E0908 EventNotEntityScoped (`await entity_event(e, T)` where T has no `Entity` field) ambiguous_event_entity_target, // M1.0.14 E2 — E0909 AmbiguousEventEntityTarget (T has multiple `Entity` fields with no `@entity_target`) + measure_outside_test, // M1.0.15 E4 — E0910 MeasureOutsideTest (`measure { … }` outside a test body; wall-clock stays out of deterministic gameplay) /// Canonical short code, e.g. `"E0001"`. pub fn code(self: DiagnosticCode) []const u8 { @@ -561,6 +562,7 @@ pub const DiagnosticCode = enum { .control_flow_escapes_task_branch => "E0907", .event_not_entity_scoped => "E0908", .ambiguous_event_entity_target => "E0909", + .measure_outside_test => "E0910", }; } @@ -756,6 +758,7 @@ pub const DiagnosticCode = enum { .control_flow_escapes_task_branch => "ControlFlowEscapesTaskBranch", .event_not_entity_scoped => "EventNotEntityScoped", .ambiguous_event_entity_target => "AmbiguousEventEntityTarget", + .measure_outside_test => "MeasureOutsideTest", }; } }; diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 5ce8d90d..e40a271c 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -1029,6 +1029,49 @@ pub const Interpreter = struct { /// `RuntimeReport.last_error` at the body choke points. Raise sites /// without a typed conversion leave it null (the report still counts). pending_error: ?RuntimeError = null, + /// Human-readable message accompanying the in-flight failure (M1.0.15): the + /// `assert(cond, "msg")` literal, or an assertion-family builtin's formatted + /// compared-values string. Rides alongside `pending_error` so `runTestBody` + /// can report it as the per-test failure message. Points at AST-stable bytes + /// (assert literal) or into `test_msg_buf` (formatted messages); `null` ⇒ the + /// runner derives a message from the error kind. Read only on the test path. + pending_message: ?[]const u8 = null, + /// Owned scratch for a formatted assertion-failure message (M1.0.15). The + /// assertion-family builtins (E4) write here; `pending_message` then points + /// into it. Overwritten per failure, freed at `deinit`; the runner copies the + /// message out before the next test runs. + test_msg_buf: std.ArrayListUnmanaged(u8) = .empty, + /// One-shot: skip the next `stepOnce` head event-store clear (M1.0.15). The + /// test-world `world.tick(n)` sets it when events were emitted BEFORE the tick + /// (by the test body's `world.emit`, or by observers fired during + /// `world.spawn_with`) so those events survive the first tick's head-clear and + /// reach that tick's rules — §32's `emit; tick(1)` semantics. Consumed by the + /// first `stepOnce`; `runFor` never sets it (identical to the pre-M1.0.15 path). + suppress_event_clear: bool = false, + /// Monotonic-clock provider for the wall-clock `measure { … }` expression + /// (M1.0.15). Zig 0.16 clocks live under `std.Io.Clock`, which needs an `io`; + /// the test runner sets this before `runTestBody`. `null` on every non-test + /// path — and `measure` is E0910 outside a test, so it is never evaluated + /// there; a null here at a `measure` site is a fail-loud belt. + io: ?std.Io = null, + /// Whether a `test` body is currently executing (M1.0.15). Gates the runtime + /// interception of the TEST-SCOPED builtins `test_world` / `tick_until` in + /// `evalBuiltinCall`, mirroring the type-checker's `in_test_body` gate — so a + /// user top-level `fn test_world()` / `fn tick_until()` called OUTSIDE a test + /// is not shadowed by the builtin (the checker binds it to the user fn). + /// Set for the duration of `runTestBody`. The global assertion family + + /// panic/todo/unreachable resolve regardless (symmetric with the checker). + in_test_body: bool = false, + /// While TRUE, the per-body value-store resets (`resetBodyStores`) are no-ops + /// (M1.0.15). A `test` body OUTLIVES the rule / guard / timer / observer / + /// hook bodies that `world.tick(n)` drives; those bodies' per-body resets + /// would free heap-backed values (arrays / structs / closures / `.string_run`) + /// still held by the test body's locals — a use-after-free (the M1.0.14 + /// string class). `runTestBody` sets this for its whole duration; the driven + /// bodies then accumulate into the shared stores (test-scale, bounded), and + /// `runTestBody`'s own end-defers (raw resets, NOT `resetBodyStores`) free + /// everything at once. `false` on every production path — no behavior change. + suppress_body_store_resets: bool = false, /// Whether a `return` is unwinding to the enclosing `fn` boundary (M0.8 E2). /// Mirrors `thrown`: every statement-run / loop / block site that stops on a /// throw also stops on a return; the fn-call boundary consumes it. @@ -1228,6 +1271,7 @@ pub const Interpreter = struct { self.descriptors.deinit(self.gpa); self.merge_cursors.deinit(self.gpa); self.gpa.free(self.observer_ctxs); + self.test_msg_buf.deinit(self.gpa); self.* = undefined; } @@ -1610,6 +1654,105 @@ pub const Interpreter = struct { return report; } + /// Outcome of running one `test` block body (M1.0.15). `pass` = the body ran + /// to completion with no failure; `fail` carries the failing source position + /// and a message (the `assert` reason, an assertion-family formatted string, + /// or a kind-derived default). The message is borrowed — from AST-stable + /// bytes or the interpreter's `test_msg_buf` — valid until the next + /// `runTestBody` / `deinit`; the runner copies it into report-owned memory + /// before the next test runs. + pub const TestBodyOutcome = union(enum) { + pass, + fail: TestFailure, + }; + + pub const TestFailure = struct { + span: SourceSpan, + message: []const u8, + }; + + /// Execute one `test` block body to completion (M1.0.15, §32 normative). The + /// body is a SYNC context (the type-checker rejected `await`), so nothing + /// suspends — the statements run once. A failed `assert*`, an uncaught + /// `throw`, or any runtime failure becomes a `.fail` outcome carrying the + /// position + message (NOT an aggregated `runtime_errors` count — §32). + /// Mirrors `execTimerBody`'s sync run-to-completion discipline with a fresh, + /// param-less locals scope. Suppresses the per-body store resets of every + /// body `tick(n)` drives (rule / guard / timer / observer / hook) for the + /// whole test body: those bodies must not free heap-backed values the test's + /// locals still hold (UAF, M1.0.14 class). The stores accumulate (test-scale, + /// bounded) and the end-defers below free them en bloc — RAW resets, so they + /// run unconditionally even while the suppression flag is (about to be) set. + pub fn runTestBody(self: *Interpreter, world: *World, decl: ast_mod.TestDecl) error{OutOfMemory}!TestBodyOutcome { + var locals: Locals = .{}; + defer locals.deinit(self.gpa); + self.suppress_body_store_resets = true; + defer self.suppress_body_store_resets = false; + self.in_test_body = true; + defer self.in_test_body = false; + defer self.collections.reset(self.gpa); + defer self.closures.reset(self.gpa); + defer self.structs.reset(self.gpa); + defer self.optionals.clearRetainingCapacity(); + defer self.resetRunStrings(); + + self.control = .none; + self.thrown = false; + self.returning = false; + self.pending_error = null; + self.pending_message = null; + + const blk = self.ast.block_exprs.items[self.ast.exprData(decl.body)]; + var s: u32 = 0; + while (s < blk.body_len) : (s += 1) { + const stmt_id: NodeId = @bitCast(self.ast.extra.items[blk.body_start + s]); + self.execStmt(world, &locals, stmt_id) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.RuntimeFailure => return self.testFailure(), + }; + if (self.thrown) return self.testThrow(); + // A top-level `return` / `break` / `continue` ends the body (no value). + if (self.control != .none) { + self.control = .none; + self.control_label = 0; + return .pass; + } + if (self.returning) { + self.returning = false; + self.return_value = .{ .unit = {} }; + return .pass; + } + } + // A trailing block value (rare in a test) is evaluated for effect. + if (!blk.value.isNone()) { + _ = self.evalExpr(world, &locals, blk.value) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.RuntimeFailure => return self.testFailure(), + }; + if (self.thrown) return self.testThrow(); + } + return .pass; + } + + /// Build a `.fail` outcome from the in-flight `pending_error`/`pending_message` + /// (M1.0.15). Message = `pending_message` when set, else a stable default + /// derived from the error kind. + fn testFailure(self: *Interpreter) TestBodyOutcome { + const span = if (self.pending_error) |pe| pe.span else SourceSpan{ .byte_start = 0, .byte_end = 0 }; + const kind = if (self.pending_error) |pe| pe.kind else RuntimeErrorKind.UnsupportedExpr; + const msg = self.pending_message orelse defaultFailureMessage(kind); + return .{ .fail = .{ .span = span, .message = msg } }; + } + + /// `.fail` outcome for an uncaught `throw` reaching the test top level + /// (M1.0.15): consume the flag, record the typed payload, then convert. + fn testThrow(self: *Interpreter) TestBodyOutcome { + self.thrown = false; + self.pending_error = .{ .kind = .UncaughtThrow, .span = self.thrown_span }; + self.pending_message = null; + return self.testFailure(); + } + /// M1.0.9 — give the interpreter a runtime extension resolver (name → cooked /// `.prefab.bin` bytes, the same interface the scene loader receives) so an /// Etch `entity.activate_extension("X")` / `deactivate_extension("X")` @@ -1698,11 +1841,7 @@ pub const Interpreter = struct { var locals: Locals = .{}; defer locals.deinit(self.gpa); - defer self.collections.reset(self.gpa); - defer self.closures.reset(self.gpa); - defer self.structs.reset(self.gpa); - defer self.optionals.clearRetainingCapacity(); - defer self.resetRunStrings(); + defer self.resetBodyStores(); // Bind the declared params by name (the names are validated in E2): // `entity` → the triggering entity; `value`/`new` → new_value bytes; @@ -1797,11 +1936,7 @@ pub const Interpreter = struct { var locals: Locals = .{}; defer locals.deinit(self.gpa); - defer self.collections.reset(self.gpa); - defer self.closures.reset(self.gpa); - defer self.structs.reset(self.gpa); - defer self.optionals.clearRetainingCapacity(); - defer self.resetRunStrings(); + defer self.resetBodyStores(); // Bind the implicit `entity` — only if the body references it (else the // name is not interned in the hook arena and no binding is needed). @@ -1918,8 +2053,15 @@ pub const Interpreter = struct { self.async_tick += 1; self.advanceTime(world); // Events have a per-tick lifetime (`Lifetime.tick`): clear the previous - // tick's queue before running this tick's rules (M0.8 E3). - self.events.clear(self.gpa); + // tick's queue before running this tick's rules (M0.8 E3). The test-world + // `tick(n)` suppresses exactly this first clear (M1.0.15) so events + // emitted before the tick (`world.emit`, or a `spawn_with` observer) + // survive into it — §32's `emit; tick(1)`. + if (self.suppress_event_clear) { + self.suppress_event_clear = false; + } else { + self.events.clear(self.gpa); + } // Fire due timers (M1.0.13 E6) — after the clock advance and the // event-queue clear, before rule dispatch: a callback's `emit` lands // in THIS tick's store, visible to every rule of the tick. @@ -2345,11 +2487,15 @@ pub const Interpreter = struct { return v == .bool_ and v.bool_; } - /// Reset the rule-arena stores after a guard evaluation (M0.8 E4): guard - /// expressions may allocate (strings, collections); nothing they create - /// outlives the guard verdict, and the body starts from a clean arena - /// (its own boundary resets are unchanged). - fn resetGuardStores(self: *Interpreter) void { + /// Reset the shared per-body value stores (collections / closures / structs / + /// optionals / run-strings) at a body boundary. No-op while a `test` body is + /// running (`suppress_body_store_resets`, M1.0.15): that body outlives the + /// rule / guard / timer / observer / hook bodies `tick(n)` drives, so their + /// resets must not free heap-backed values its locals still hold (UAF, the + /// M1.0.14 class). The single choke point for every per-body reset; a test's + /// own end-cleanup calls the raw resets directly (unconditional). + fn resetBodyStores(self: *Interpreter) void { + if (self.suppress_body_store_resets) return; self.collections.reset(self.gpa); self.closures.reset(self.gpa); self.structs.reset(self.gpa); @@ -2357,6 +2503,15 @@ pub const Interpreter = struct { self.resetRunStrings(); } + /// Reset the rule-arena stores after a guard evaluation (M0.8 E4): guard + /// expressions may allocate (strings, collections); nothing they create + /// outlives the guard verdict, and the body starts from a clean arena + /// (its own boundary resets are unchanged). Routed through `resetBodyStores` + /// so a test body suppresses it too (M1.0.15). + fn resetGuardStores(self: *Interpreter) void { + self.resetBodyStores(); + } + /// Run an `@on_event(T)` observer (M0.8 E3): fire the body once per event of /// type `event_type` currently in the per-tick `EventStore`, in emit order. /// The list length is re-checked each iteration so an event the body itself @@ -3853,12 +4008,10 @@ pub const Interpreter = struct { defer locals.deinit(self.gpa); // Collections, closures, and structs created in this body live in the // rule arena: free them at the body boundary so handles never outlive - // their invocation. - defer self.collections.reset(self.gpa); - defer self.closures.reset(self.gpa); - defer self.structs.reset(self.gpa); - defer self.optionals.clearRetainingCapacity(); - defer self.resetRunStrings(); + // their invocation — UNLESS a test body is driving this rule via + // `tick(n)`, in which case the reset is deferred to the test body's end + // (`resetBodyStores` no-ops; M1.0.15, the outlives-UAF fix). + defer self.resetBodyStores(); try bindParams(self.gpa, self.ast, rule, entity_id, &locals); // `@on_event(T)` observer (M0.8 E3): inject the implicit `event` payload // self-style (like `self` in `callMethod`). The event is materialised as @@ -3934,6 +4087,11 @@ pub const Interpreter = struct { report.runtime_errors += 1; if (self.pending_error) |pe| report.last_error = pe; self.pending_error = null; + // Clear the assert-message sideband too (M1.0.15 review fix): a rule-body + // assertion failure harvested during a test's `tick(n)` must not leave a + // stale `pending_message` for a LATER `fail()`-routed test-body failure + // (division-by-zero, etc.) to mis-report as the rule's assert text. + self.pending_message = null; } /// Run a contiguous statement run, stopping early if a control signal @@ -4022,11 +4180,17 @@ pub const Interpreter = struct { }, .assert_stmt => { // `assert(cond)` — a false condition is a runtime failure (the - // dev-build panic of `etch-reference-part1.md` §10.3, surfaced - // here as a counted RuntimeReport error). + // dev-build panic of `etch-reference-part1.md` §10.3). Raised via + // `fail(.AssertFailed, span)` so the failure carries the source + // position (M1.0.15): a rule-body assert still counts into the + // report; a test-body assert becomes the per-test failure. The + // optional message literal rides on `pending_message`. const a = self.ast.assert_stmts.items[data]; const v = try self.evalExpr(world, locals, a.cond); - if (v != .bool_ or !v.bool_) return error.RuntimeFailure; + if (v != .bool_ or !v.bool_) { + self.pending_message = if (a.message != 0) self.ast.strings.slice(a.message) else null; + return self.fail(.AssertFailed, self.ast.exprSpan(a.cond)); + } }, .for_stmt => { // `for v in range/array/map { body }` (M0.8). The loop variable @@ -4164,43 +4328,11 @@ pub const Interpreter = struct { self.returning = true; }, .emit_stmt => { - // `emit EventType { field: value, … }` (M0.8 E3). Evaluate the - // field initializers and enqueue the event in the dynamic event - // store. The comptime-typed `World.event_bus` is unusable by the - // tree-walker; `@on_event` observers drain this store (deferred - // to the E3 observer tranche, resolver-types §12). + // `emit EventType { field: value, … }` (M0.8 E3). Shared with the + // test-world `world.emit(T {…})` method form (M1.0.15) via + // `enqueueEvent`. const em = self.ast.emit_stmts.items[data]; - // The event decl (for enum-shorthand field resolution) is known - // only for MAIN-arena events; a re-parsed extension hook - // (`execHookText`) runs against a transient arena whose event - // type ids are not in `event_decls`, so fall back to plain - // `evalExpr` there — its emit fields are the M1.0.9 cookable - // subset, which has no enum shorthand. - const edecl_opt = self.event_decls.get(em.event_type); - var fields: std.ArrayListUnmanaged(StructField) = .empty; - errdefer fields.deinit(self.gpa); - var i: u32 = 0; - while (i < em.fields_len) : (i += 1) { - const flit = self.ast.struct_lit_fields.items[em.fields_start + i]; - // Resolve enum-shorthand field values against the declared - // enum type (M1.0.14 E4 fix-as-you-go — a bare `.Variant` - // event field was fail-loud in generic `evalExpr` before). - const v = if (edecl_opt) |edecl| - try self.evalEventFieldValue(world, locals, edecl, flit) - else - try self.evalExpr(world, locals, flit.value); - // Stabilize a non-AST string (a per-body `.string_run` or a - // borrowed `.string_persistent`): its bytes are freed at the - // body boundary / on resource-string reassignment, but the - // enqueued event outlives the body (observers / cross-tick - // awaiters), so deep-copy into the store (M1.0.14 E4). - const stable = if (stringNeedsOwning(v)) - try self.events.ownEscapingString(self.gpa, self.stringBytes(v).?) - else - v; - try fields.append(self.gpa, .{ .name = flit.name, .value = stable }); - } - try self.events.enqueue(self.gpa, em.event_type, fields); + try self.enqueueEvent(world, locals, em.event_type, em.fields_start, em.fields_len); }, .tag_mutation_stmt => { // `entity.add_tag(.path)` / `entity.remove_tag(.path)` (M0.8 E3, @@ -4549,6 +4681,316 @@ pub const Interpreter = struct { /// Components are POD-strict (no heap fields), so `writeValueAsBytes` covers /// every valid field kind. The type-checker (E2) has already validated the /// type is a declared component and the fields exist + type-match. + /// Evaluate an event field-init run and enqueue the event into the per-tick + /// `EventStore` (M0.8 E3; shared by the `emit` statement and the test-world + /// `world.emit(T {…})` method form, M1.0.15). Enum-shorthand field values + /// resolve against the declared event (main-arena events only); non-AST + /// strings are deep-copied into the store so the enqueued event outlives the + /// body (observers / cross-tick awaiters), per the M1.0.14 E4 discipline. + fn enqueueEvent(self: *Interpreter, world: *World, locals: *Locals, event_type: StringId, fields_start: u32, fields_len: u32) StmtError!void { + const edecl_opt = self.event_decls.get(event_type); + var fields: std.ArrayListUnmanaged(StructField) = .empty; + errdefer fields.deinit(self.gpa); + var i: u32 = 0; + while (i < fields_len) : (i += 1) { + const flit = self.ast.struct_lit_fields.items[fields_start + i]; + const v = if (edecl_opt) |edecl| + try self.evalEventFieldValue(world, locals, edecl, flit) + else + try self.evalExpr(world, locals, flit.value); + const stable = if (stringNeedsOwning(v)) + try self.events.ownEscapingString(self.gpa, self.stringBytes(v).?) + else + v; + try fields.append(self.gpa, .{ .name = flit.name, .value = stable }); + } + try self.events.enqueue(self.gpa, event_type, fields); + } + + /// M1.0.15 — evaluate `world.spawn_with([C {…}, …])`: build each component + /// literal's payload eagerly, then spawn IMMEDIATELY through the shared + /// observer-firing primitive (`world.spawnWithObservers` — on_spawned + + /// on_add), returning the live `Entity` handle (usable same-tick, §32). The + /// scratch arena holds the payloads only until the spawn memcpies them into + /// storage. A test body's collection / struct / string values also survive a + /// later `tick()`: `runTestBody` suppresses the driven bodies' store resets + /// (`resetBodyStores`), so nothing frees them from under the test. + fn evalSpawnWith(self: *Interpreter, world: *World, locals: *Locals, mc: ast_mod.MethodCall) StmtError!Value { + if (mc.args_len != 1) return error.RuntimeFailure; + const arg: NodeId = @bitCast(self.ast.extra.items[mc.args_start]); + if (self.ast.exprKind(arg) != .array_lit) return error.RuntimeFailure; + const al = self.ast.array_lits.items[self.ast.exprData(arg)]; + if (al.is_fill) return error.RuntimeFailure; + + var arena = std.heap.ArenaAllocator.init(self.gpa); + defer arena.deinit(); + const aa = arena.allocator(); + const ids = try aa.alloc(ComponentId, al.elements_len); + const payloads = try aa.alloc([]const u8, al.elements_len); + var i: u32 = 0; + while (i < al.elements_len) : (i += 1) { + const elem: NodeId = @bitCast(self.ast.extra.items[al.elements_start + i]); + const p = try self.buildComponentPayload(world, locals, elem, aa); + ids[i] = p.cid; + payloads[i] = p.bytes; + } + const eid = world.spawnWithObservers(self.gpa, ids, payloads) catch return error.RuntimeFailure; + return Value{ .entity_id = @bitCast(eid) }; + } + + /// M1.0.15 — evaluate `world.emit(T {…})`: the `emit` statement semantics on + /// the test world's per-tick EventStore (shared `enqueueEvent`). + fn evalWorldEmit(self: *Interpreter, world: *World, locals: *Locals, mc: ast_mod.MethodCall) StmtError!void { + if (mc.args_len != 1) return error.RuntimeFailure; + const arg: NodeId = @bitCast(self.ast.extra.items[mc.args_start]); + if (self.ast.exprKind(arg) != .struct_lit) return error.RuntimeFailure; + const sl = self.ast.struct_lits.items[self.ast.exprData(arg)]; + try self.enqueueEvent(world, locals, sl.type_name, sl.fields_start, sl.fields_len); + } + + /// M1.0.15 — evaluate `world.tick(n)`: advance the test world by `n` ticks + /// (`n >= 1`), each a `stepOnce` + `world.tickBoundary()` — the canonical tick + /// runFor drives. Rule-tick runtime errors are counted into a throwaway + /// report (they do not fail the test — a test fails on its OWN body's + /// assert/throw/failure, §32; the body's assertions catch a wrong result). + fn evalWorldTick(self: *Interpreter, world: *World, locals: *Locals, mc: ast_mod.MethodCall) StmtError!void { + if (mc.args_len != 1) return error.RuntimeFailure; + const arg: NodeId = @bitCast(self.ast.extra.items[mc.args_start]); + const v = try self.evalExpr(world, locals, arg); + if (v != .int_ or v.int_ < 1) return error.RuntimeFailure; + // Events emitted before this tick (test body / `spawn_with` observers) + // must reach the first tick's rules: suppress that tick's head-clear. + if (self.events.list.items.len > 0) self.suppress_event_clear = true; + var report: RuntimeReport = .{}; + var t: i64 = 0; + while (t < v.int_) : (t += 1) { + self.stepOnce(world, &report) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.RuntimeFailure, + }; + world.tickBoundary(); + } + // Leave the store clean so a later `emit; tick` in the same test does not + // re-see this tick's consumed events (the normal next-tick head-clear, + // applied here since there is no next tick in this call). + self.events.clear(self.gpa); + } + + fn evalArg(self: *Interpreter, world: *World, locals: *Locals, call: ast_mod.CallExpr, i: u32) StmtError!Value { + const arg: NodeId = @bitCast(self.ast.extra.items[call.args_start + i]); + return try self.evalExpr(world, locals, arg); + } + + /// Append raw bytes to the assertion-message scratch (`test_msg_buf`). + fn msgAppend(self: *Interpreter, bytes: []const u8) StmtError!void { + try self.test_msg_buf.appendSlice(self.gpa, bytes); + } + + /// Append `fmt`/`args` (allocPrint + appendSlice — the house pattern; the + /// ArrayList has no `writer` in this Zig). + fn msgPrint(self: *Interpreter, comptime fmt: []const u8, args: anytype) StmtError!void { + const piece = try std.fmt.allocPrint(self.gpa, fmt, args); + defer self.gpa.free(piece); + try self.test_msg_buf.appendSlice(self.gpa, piece); + } + + /// Append a Value as human-readable text for an assertion-failure message + /// (M1.0.15). Scalars + strings + Duration render faithfully; a richer value + /// falls back to a placeholder (assert messages target the comparable cases). + fn msgValue(self: *Interpreter, v: Value) StmtError!void { + switch (v) { + .int_ => |x| try self.msgPrint("{d}", .{x}), + .float_ => |x| try self.msgPrint("{d}", .{x}), + .bool_ => |x| try self.msgAppend(if (x) "true" else "false"), + .duration => |x| try self.msgPrint("{d}s", .{x}), + .string_id, .string_run, .string_persistent => try self.msgAppend(self.stringBytes(v) orelse ""), + .entity_id => |e| try self.msgPrint("entity#{d}", .{e}), + .unit => try self.msgAppend("()"), + else => try self.msgAppend(""), + } + } + + /// Raise an assertion failure whose message has already been written into + /// `test_msg_buf` (M1.0.15): point `pending_message` at it and fail. The + /// runner copies the message out; outside a test the failure is harvested + /// into the report (like the `assert` statement). + fn raiseAssert(self: *Interpreter, span: SourceSpan) error{RuntimeFailure} { + self.pending_message = self.test_msg_buf.items; + self.pending_error = .{ .kind = .AssertFailed, .span = span }; + return error.RuntimeFailure; + } + + /// Call a zero-argument closure Value to completion and return its result + /// (M1.0.15 — the `tick_until` predicate). Mirrors the `fn_call` closure + /// invocation: frame from the captured env, evaluate the body, consume a + /// `return`. Fails loud on a non-closure or a nonzero arity. + fn callZeroArgClosure(self: *Interpreter, world: *World, cv: Value) StmtError!Value { + if (cv != .closure) return error.RuntimeFailure; + const handle = cv.closure; + const node = self.closures.list.items[handle].node; + const ce = self.ast.closure_exprs.items[self.ast.exprData(node)]; + if (ce.params_len != 0) return error.RuntimeFailure; + var frame: Locals = .{}; + defer frame.deinit(self.gpa); + var cap_it = self.closures.list.items[handle].captured.iterator(); + while (cap_it.next()) |e| try frame.put(self.gpa, e.key_ptr.*, e.value_ptr.*, false); + const result = try self.evalExpr(world, &frame, ce.body); + if (self.returning) { + self.returning = false; + const rv = self.return_value; + self.return_value = .{ .unit = {} }; + return rv; + } + if (self.thrown or self.control != .none) return Value{ .unit = {} }; + return result; + } + + /// `tick_until(pred: () -> bool, timeout: Duration) -> bool` (M1.0.15): tick + /// the world, evaluating `pred` after each tick, until it is true or the + /// budget (`round(secs · 60)` ticks, the `wait` conversion) is exhausted. + fn evalTickUntil(self: *Interpreter, world: *World, locals: *Locals, call: ast_mod.CallExpr) StmtError!Value { + if (call.args_len != 2) return error.RuntimeFailure; + const pred = try self.evalArg(world, locals, call, 0); + const timeout = try self.evalArg(world, locals, call, 1); + if (timeout != .duration) return error.RuntimeFailure; + const budget: i64 = @intFromFloat(@round(timeout.duration * async_fixed_dt_hz)); + // Keep the event store clean for a later `emit; tick` in the same test. + defer self.events.clear(self.gpa); + if (self.events.list.items.len > 0) self.suppress_event_clear = true; + var report: RuntimeReport = .{}; + var i: i64 = 0; + while (i < budget) : (i += 1) { + self.stepOnce(world, &report) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.RuntimeFailure, + }; + world.tickBoundary(); + const r = try self.callZeroArgClosure(world, pred); + // A predicate `throw` / control signal stops the loop and surfaces as + // the test failure (M1.0.15 review fix) — do NOT keep ticking on a + // latched throw. `callZeroArgClosure` consumes `return`; only a throw + // or a stray control signal can remain set here. + if (self.thrown) { + self.thrown = false; + self.pending_error = .{ .kind = .UncaughtThrow, .span = self.thrown_span }; + self.pending_message = null; + return error.RuntimeFailure; + } + if (self.control != .none) return error.RuntimeFailure; + if (r == .bool_ and r.bool_) return Value{ .bool_ = true }; + } + return Value{ .bool_ = false }; + } + + /// Evaluate a builtin free-function call by name (M1.0.15). Returns the value + /// when `name` is a builtin, else null (the caller falls to the user-fn + /// lookup). Mirrors `synthBuiltinCall`'s name set. An assertion failure + /// formats a message into `test_msg_buf` and raises `fail(.AssertFailed)`; + /// the runner reports it per-test, and outside a test it is harvested into + /// the report exactly like the `assert` statement. + fn evalBuiltinCall(self: *Interpreter, world: *World, locals: *Locals, id: NodeId, call: ast_mod.CallExpr, callee_name: StringId) StmtError!?Value { + const name = self.ast.strings.slice(callee_name); + const span = self.ast.exprSpan(id); + + // Test-scoped builtins are intercepted ONLY inside a test body — mirrors + // the type-checker's `in_test_body` gate (M1.0.15 review fix), so a user + // fn of the same name outside a test resolves to the user fn (the checker + // bound it there), not the builtin. + if (self.in_test_body) { + if (std.mem.eql(u8, name, "test_world")) return Value{ .world_handle = {} }; + if (std.mem.eql(u8, name, "tick_until")) return try self.evalTickUntil(world, locals, call); + } + + if (std.mem.eql(u8, name, "assert_eq") or std.mem.eql(u8, name, "assert_neq")) { + const want_eq = std.mem.eql(u8, name, "assert_eq"); + if (call.args_len < 2) return error.RuntimeFailure; + const a = try self.evalArg(world, locals, call, 0); + const b = try self.evalArg(world, locals, call, 1); + // String-aware equality (M1.0.15 review fix): `Value.eql` compares + // strings only by pool identity (`.string_run` is always unequal, and + // a `.string_id` literal never matches a `.string_persistent` / + // `.string_run`), which would false-fail `assert_eq` and — worse — + // false-PASS `assert_neq`. `eventValueEql` byte-compares strings and + // falls back to `Value.eql` otherwise. Aggregates are rejected at + // type-check (`synthBuiltinCall`), so only comparable values arrive. + if (self.eventValueEql(a, b) != want_eq) { + self.test_msg_buf.clearRetainingCapacity(); + try self.msgAssertPrefix(world, locals, call, 2, name); + try self.msgAppend(": "); + try self.msgValue(a); + try self.msgAppend(if (want_eq) " != " else " == "); + try self.msgValue(b); + return self.raiseAssert(span); + } + return Value{ .unit = {} }; + } + if (std.mem.eql(u8, name, "assert_approx")) { + if (call.args_len < 2) return error.RuntimeFailure; + const a = try self.evalArg(world, locals, call, 0); + const b = try self.evalArg(world, locals, call, 1); + if (a != .float_ or b != .float_) return error.RuntimeFailure; + var tol: f64 = 1e-6; + if (call.args_len >= 3) { + const t = try self.evalArg(world, locals, call, 2); + if (t != .float_) return error.RuntimeFailure; + tol = t.float_; + } + if (@abs(a.float_ - b.float_) > tol) { + self.test_msg_buf.clearRetainingCapacity(); + try self.msgAssertPrefix(world, locals, call, 3, name); + try self.msgPrint(": {d} vs {d} (tolerance {d})", .{ a.float_, b.float_, tol }); + return self.raiseAssert(span); + } + return Value{ .unit = {} }; + } + if (std.mem.eql(u8, name, "assert_some") or std.mem.eql(u8, name, "assert_none")) { + const want_some = std.mem.eql(u8, name, "assert_some"); + if (call.args_len < 1) return error.RuntimeFailure; + const v = try self.evalArg(world, locals, call, 0); + if (v != .optional) return error.RuntimeFailure; + const is_some = self.optionals.items[v.optional] != null; + if (is_some != want_some) { + self.test_msg_buf.clearRetainingCapacity(); + try self.msgAssertPrefix(world, locals, call, 1, name); + try self.msgAppend(if (want_some) ": value is none" else ": value is some"); + return self.raiseAssert(span); + } + return Value{ .unit = {} }; + } + if (std.mem.eql(u8, name, "panic")) { + if (call.args_len != 1) return error.RuntimeFailure; + const m = try self.evalArg(world, locals, call, 0); + self.test_msg_buf.clearRetainingCapacity(); + try self.msgAppend(self.stringBytes(m) orelse "panic"); + return self.raiseAssert(span); + } + if (std.mem.eql(u8, name, "todo") or std.mem.eql(u8, name, "unreachable")) { + self.test_msg_buf.clearRetainingCapacity(); + if (call.args_len == 1) { + const m = try self.evalArg(world, locals, call, 0); + try self.msgAppend(self.stringBytes(m) orelse ""); + } else { + try self.msgAppend(if (std.mem.eql(u8, name, "todo")) "not yet implemented" else "unreachable code reached"); + } + return self.raiseAssert(span); + } + return null; + } + + /// Append the leading part of an assertion-failure message into `test_msg_buf`: + /// the user's custom message argument (a string at `msg_idx`) if present, else + /// the builtin name (M1.0.15). The caller appends the specifics. + fn msgAssertPrefix(self: *Interpreter, world: *World, locals: *Locals, call: ast_mod.CallExpr, msg_idx: u32, name: []const u8) StmtError!void { + if (msg_idx < call.args_len) { + const m = try self.evalArg(world, locals, call, msg_idx); + if (self.stringBytes(m)) |bytes| { + try self.msgAppend(bytes); + return; + } + } + try self.msgAppend(name); + } + fn buildComponentPayload(self: *Interpreter, world: *World, locals: *Locals, struct_lit_arg: NodeId, alloc: std.mem.Allocator) StmtError!struct { cid: ComponentId, bytes: []u8 } { if (self.ast.exprKind(struct_lit_arg) != .struct_lit) return error.RuntimeFailure; const sl = self.ast.struct_lits.items[self.ast.exprData(struct_lit_arg)]; @@ -4596,6 +5038,22 @@ pub const Interpreter = struct { /// resolver's `dispatchMethodOnType` split. fn dispatchMethodOnValue(self: *Interpreter, world: *World, locals: *Locals, mc: ast_mod.MethodCall, recv: Value) StmtError!Value { switch (recv) { + .world_handle => { + // M1.0.15 — the test World surface (§32). The mono-world handle + // operates on the `world` already threaded here (the test's fresh + // world). Type-check gated the shapes; the fail-loud belt stays. + const mname = self.ast.strings.slice(mc.method_name); + if (std.mem.eql(u8, mname, "spawn_with")) return try self.evalSpawnWith(world, locals, mc); + if (std.mem.eql(u8, mname, "emit")) { + try self.evalWorldEmit(world, locals, mc); + return Value{ .unit = {} }; + } + if (std.mem.eql(u8, mname, "tick")) { + try self.evalWorldTick(world, locals, mc); + return Value{ .unit = {} }; + } + return error.RuntimeFailure; + }, .task_handle => |ti| { // M1.0.12 E5 — the TaskHandle's single method (§9.8): // `h.cancel()` is IDEMPOTENT — cancels a suspended task, a @@ -5343,6 +5801,11 @@ pub const Interpreter = struct { if (self.ast.exprKind(call.callee) == .ident) { const callee_name = self.ast.exprData(call.callee); if (locals.get(callee_name) == null) { + // Builtin free functions (M1.0.15): assertion family + + // panic/todo/unreachable + test_world/tick_until. Resolved + // before the user-fn lookup (the type-checker already gated + // scope), mirroring `synthBuiltinCall`. + if (try self.evalBuiltinCall(world, locals, id, call, callee_name)) |v| return v; if (self.fns.get(callee_name)) |fndecl| { return try self.callFn(world, locals, fndecl, call); } @@ -5482,6 +5945,23 @@ pub const Interpreter = struct { if (blk.value.isNone()) return Value{ .unit = {} }; return try self.evalExpr(world, locals, blk.value); }, + .measure_expr => { + // `measure { block }` (M1.0.15, §17 erratum): run the block once, + // return the elapsed WALL-CLOCK as a `Duration` (seconds). The + // type-checker gates it to test bodies (E0910), so `self.io` (set + // by the runner) is present; a null is a fail-loud belt. The + // block's own value is discarded — only the timing is returned. + const m = self.ast.measure_exprs.items[data]; + const io = self.io orelse return error.RuntimeFailure; + const t0 = std.Io.Clock.now(.awake, io); + try self.execStmtRun(world, locals, m.body_start, m.body_len); + if (!m.value.isNone() and self.control == .none and !self.thrown and !self.returning) { + _ = try self.evalExpr(world, locals, m.value); + } + const t1 = std.Io.Clock.now(.awake, io); + const ns: u64 = @intCast(@max(@as(i96, 0), t0.durationTo(t1).nanoseconds)); + return Value{ .duration = @as(f64, @floatFromInt(ns)) / @as(f64, std.time.ns_per_s) }; + }, .if_expr => { const ife = self.ast.if_exprs.items[data]; // `if let x = { then } [else { else }]` (M0.8 E2 block @@ -5643,6 +6123,21 @@ fn bridgeFailureKind(err: anyerror) RuntimeErrorKind { }; } +/// Stable default message for a test failure with no `pending_message` +/// (M1.0.15) — a runtime failure (division-by-zero, overflow, …) or an uncaught +/// throw. All returned strings are static, so the `.fail` outcome can borrow +/// them until the runner copies the message out. +fn defaultFailureMessage(kind: RuntimeErrorKind) []const u8 { + return switch (kind) { + .DivisionByZero => "division by zero", + .IntegerOverflow => "integer overflow", + .UnsupportedExpr => "unsupported expression", + .TypeMismatch => "type mismatch", + .UncaughtThrow => "uncaught throw", + .AssertFailed => "assertion failed", + }; +} + /// Classify a `binaryArith` failure into a typed report kind (M0.8 E3-D, /// D-S4-runtime-report). Integer `/` and `%` fail on a zero divisor /// (DivisionByZero) or on `i64.min / -1` (IntegerOverflow — the only other @@ -5717,6 +6212,21 @@ fn binaryCompare(op: ast_mod.BinaryOp, a: Value, b: Value) !Value { }; return Value{ .bool_ = r }; } + // Duration ordering (M1.0.15) — `elapsed < 0.1s`, `measure { … } > 0.0s`. + // The type-checker already types `Duration Duration` as bool + // (`synthBinary` matching-builtin arm); this is the runtime half. + if (a == .duration and b == .duration) { + const r = switch (op) { + .eq => a.duration == b.duration, + .neq => a.duration != b.duration, + .lt => a.duration < b.duration, + .gt => a.duration > b.duration, + .le => a.duration <= b.duration, + .ge => a.duration >= b.duration, + else => return error.RuntimeFailure, + }; + return Value{ .bool_ = r }; + } return error.RuntimeFailure; } @@ -7503,6 +8013,31 @@ test "runProgram while let unwraps an optional each iteration (M0.8 E2 block 5)" try std.testing.expectEqual(@as(i64, 6), out); } +test "user fn named test_world is not shadowed by the builtin outside a test (M1.0.15 review fix)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + // Outside a test body the interpreter must call the USER fn (the type-checker + // bound the call to it), not intercept the test-scoped builtin. The rule + // writes 42 (the user fn's result) into a resource — a `world_handle` here + // would fail loud on the assignment instead. + const report = try Interpreter.runProgram(gpa, + \\resource Out { v: i32 = 0 } + \\fn test_world() -> i32 { 42 } + \\rule r() + \\ when resource Out + \\{ + \\ get_mut(Out).v = test_world() + \\} + , &world, 1); + try std.testing.expectEqual(@as(u64, 0), report.runtime_errors); + const out_id = world.registry.idOf("Out").?; + const bytes = world.resources.getResource(out_id).?; + var v: i32 = undefined; + @memcpy(std.mem.asBytes(&v), bytes[0..@sizeOf(i32)]); + try std.testing.expectEqual(@as(i32, 42), v); +} + test "runProgram assert passes on true, reports a runtime error on false (M0.8 assert)" { const gpa = std.testing.allocator; diff --git a/src/etch/parser.zig b/src/etch/parser.zig index 40cef1aa..2225077c 100644 --- a/src/etch/parser.zig +++ b/src/etch/parser.zig @@ -846,13 +846,11 @@ pub const Parser = struct { /// `test_decl = "test" STRING_LITERAL block`. /// The body reuses the ordinary block/statement parser (`parseBlockExpr`). /// `@tag` / `@skip` / `@only` annotations flow through `parseAnnotations` - /// before dispatch (the range is discarded here — the v0.6 test subset - /// attaches no resolver semantics to them). M1.0.8 is parse + validate + - /// symbol registration only; there is no execution surface (M1.0.9). The - /// `kw_test` starter is mirrored in `recoverToTopLevel`'s stop-set + the - /// `parseTopLevel` error enumeration. + /// before dispatch; the range is preserved on the `TestDecl` (M1.0.15 — the + /// resolver validates applicability + args). The `kw_test` starter is + /// mirrored in `recoverToTopLevel`'s stop-set + the `parseTopLevel` error + /// enumeration. fn parseTestDecl(self: *Parser, annotations: AnnotationRange) ParseError!void { - _ = annotations; // @tag/@skip/@only parsed but carry no v0.6 resolver semantics const kw_span = (try self.advance()).span; // 'test' const name_tok = try self.expect(.string_literal, "expected a test name (string literal) after 'test'"); const name_id = try self.internStringLiteral(name_tok.span); @@ -860,6 +858,8 @@ pub const Parser = struct { _ = try self.arena.addTestDecl(self.gpa, .{ .name = name_id, .body = body, + .annotations_extra = annotations.start, + .annotations_len = annotations.len, }, .{ .byte_start = kw_span.byte_start, .byte_end = self.arena.exprSpan(body).byte_end }); } @@ -5354,6 +5354,21 @@ pub const Parser = struct { }); } + /// `measure { block }` (M1.0.15, §17 erratum). Reuses the ordinary block-body + /// parser (statement run + optional trailing value); the `measure` keyword + /// tags the node so the type-checker types it as `Duration` (test-body only, + /// E0910 elsewhere) and the interpreter times the block on the wall clock. + fn parseMeasureExpr(self: *Parser) ParseError!NodeId { + const kw_span = (try self.advance()).span; // 'measure' + _ = try self.expect(.lbrace, "expected '{' to start measure block"); + const body = try self.parseBlockBody(); + const closing = try self.expect(.rbrace, "expected '}' to close measure block"); + return try self.arena.addMeasureExpr(self.gpa, body.start, body.len, body.value, .{ + .byte_start = kw_span.byte_start, + .byte_end = closing.span.byte_end, + }); + } + /// Parse `if cond block {else if cond block} [else block]` (M0.8 control /// flow, `etch-grammar.md` §3.2 l.500 / §4.1 l.618). Parsed as an /// if-expression in `parsePrimary`; in statement position it is wrapped as @@ -6082,6 +6097,17 @@ pub const Parser = struct { _ = try self.advance(); return try self.parseTagMutation(expr, .remove); }, + // `world.emit(T {…})` — the test-world event method + // (M1.0.15). `emit` is a keyword, so the `.ident` arm below + // never sees it; route it explicitly to a `method_call` + // (`parseMethodCall` interns the token slice → "emit"). Only + // valid as a call form. + .kw_emit => { + if (self.peekNext() != .lparen) { + return self.parseErrFmt(self.peekSpan(), "'emit' after '.' must be a method call, e.g. world.emit(T {{ ... }})", .{}); + } + expr = try self.parseMethodCall(expr); + }, .ident => { // `recv.method(args)` → the reserved `method_call` // kind (M0.8 E2 call mechanism, `etch-grammar.md` @@ -6718,6 +6744,7 @@ pub const Parser = struct { .kw_if => return try self.parseIf(), .kw_await => return try self.parseAwaitExpr(), .kw_spawn => return try self.parseStructuralSpawn(), + .kw_measure => return try self.parseMeasureExpr(), .lbrace => return try self.parseBlockExpr(), .lbracket => return try self.parseArrayOrMapLiteral(), .pipe => return try self.parseClosure(), @@ -7102,6 +7129,25 @@ test "parse test block" { try std.testing.expectEqual(ast_mod.ItemKind.test_decl, result.ast.items.items(.kind)[0]); const td = result.ast.test_decls.items[result.ast.items.items(.data)[0]]; try std.testing.expectEqualStrings("math works", result.ast.strings.slice(td.name)); + // M1.0.15 — the annotation range is preserved (no longer discarded): the + // single `@tag(.unit)` is reachable through the decl's annotation slab. + try std.testing.expectEqual(@as(u32, 1), td.annotations_len); + const annot = result.ast.annot_pool.items[td.annotations_extra]; + try std.testing.expectEqual(ast_mod.AnnotationKind.tag, annot.kind); +} + +test "parse measure expression (M1.0.15)" { + const gpa = std.testing.allocator; + // `measure { block }` parses as a primary expression (§17 erratum); the + // parser produces one `measure_exprs` slab entry, no diagnostics. + var result = try parse(gpa, + \\fn timed() -> Duration { + \\ measure { let x = 1 + 1 } + \\} + ); + defer result.deinit(gpa); + try std.testing.expect(result.diagnostics.len == 0); + try std.testing.expectEqual(@as(usize, 1), result.ast.measure_exprs.items.len); } test "recovery after broken const/private/test preserves following valid decl" { diff --git a/src/etch/root.zig b/src/etch/root.zig index 13cf2b4a..8f3b2790 100644 --- a/src/etch/root.zig +++ b/src/etch/root.zig @@ -50,6 +50,9 @@ comptime { // pinned by `src/core/memory/root.zig` (reached here via `weld_core.memory`). // M1.0.4 — pull the scene cook driver into the test import graph (§13). _ = @import("scene_cook.zig"); + // M1.0.15 — the test runner's inline tests (the `pub const test_runner` + // re-export pulls its declarations, NOT its `test` blocks — §13). + _ = @import("test_runner.zig"); } /// M1.0.4 scene cook — `.scene.etch` source → the neutral Tier-0 scene model @@ -96,6 +99,21 @@ pub const Interpreter = interp.Interpreter; /// interpreter internals. pub const RuntimeReport = interp.RuntimeReport; +/// Etch `test` runner (M1.0.15) — iterates a type-checked program's `test` +/// blocks in isolation and reports pass/fail/skip. Exposed at the module +/// surface so the `etch_test` shim (and, later, `weld test`) drive it without +/// reaching into the internal path. Also roots the module for its inline tests +/// (Zig 0.16 lazy analysis, `engine-zig-conventions.md` §13). +pub const test_runner = @import("test_runner.zig"); +/// Aggregate result of a `test_runner.run` — per-test results plus +/// passed/failed/skipped counts. Owns its strings (internal arena). +pub const RunReport = test_runner.RunReport; +/// One test's outcome: name, status, wall-clock duration, and (on failure) +/// message + source span. +pub const TestResult = test_runner.TestResult; +/// Whether a test passed, failed, or was skipped. +pub const TestStatus = test_runner.TestStatus; + // ─────────────────────────────────────────────────────────────────────────── // AST stable interface — Level 1 (frozen cross-phase) // @@ -249,7 +267,8 @@ fn buildExports(gpa: std.mem.Allocator, a: *const Ast, arena_index: usize, table .type_alias => .{ .name = a.type_alias_decls.items[datas[i]].name, .kind = .type_alias }, // M1.0.8 — a top-level `const` is exportable (always public — a // const cannot carry a `private` prefix). `test` blocks are NOT - // listed: tests are registered intra-module but never exported. + // listed: they live in a dedicated test-name namespace (M1.0.15, + // `TypeChecker.test_symbols`) and are never exported. .const_decl => .{ .name = a.const_decls.items[datas[i]].name, .kind = .const_ }, else => null, }; diff --git a/src/etch/test_runner.zig b/src/etch/test_runner.zig new file mode 100644 index 00000000..4b7db66d --- /dev/null +++ b/src/etch/test_runner.zig @@ -0,0 +1,618 @@ +//! Etch `test` runner (M1.0.15, `etch-reference-part2.md` §32 normative block). +//! +//! Orchestration only: iterate the `test` blocks of a type-checked AST in +//! declaration order, run each in FULL ISOLATION (a fresh `World` + fresh +//! `Interpreter` compiled from the shared AST — the E1 recon finding: type +//! registration lives in `Interpreter.compile`, so a fresh compile re-registers +//! every component / resource / event / rule), honour `@skip` / `@only`, and +//! convert each body's outcome into a per-test result carrying the test name, +//! a message, the failure source span, and the wall-clock duration. +//! +//! The body execution + failure conversion live in `interp.runTestBody` (a +//! failed `assert*`, an uncaught `throw`, or any runtime failure becomes a +//! `.fail` outcome — NOT an aggregated `runtime_errors` count, §32). The +//! test-world surface (`test_world`/`spawn_with`/`emit`/`tick`) and the +//! assertion family (`assert_eq`, `measure`, `tick_until`) land in M1.0.15 E3/E4; +//! this library is stable against them (it drives `runTestBody`, which grows the +//! builtins underneath it). +//! +//! `RunReport` OWNS its strings (an internal arena); the caller need only keep +//! `ast` alive for the duration of `run`. The `weld test` CLI (`engine-spec.md` +//! §26.1) will consume this same library — the `etch_test` shim (E5) is its +//! Phase-1 driver. + +const std = @import("std"); +const weld_core = @import("weld_core"); +const ast_mod = @import("ast.zig"); +const interp_mod = @import("interp.zig"); +const token = @import("token.zig"); + +const World = weld_core.ecs.world.World; +const Ast = ast_mod.AstArena; +const Interpreter = interp_mod.Interpreter; +const AnnotationKind = ast_mod.AnnotationKind; +const SourceSpan = token.SourceSpan; +const Io = std.Io; + +/// Per-test outcome status. +pub const TestStatus = enum { passed, failed, skipped }; + +/// One test's result. All strings are owned by the enclosing `RunReport`'s +/// arena (copied out of interpreter/AST memory), so the report can outlive the +/// AST and the transient per-test interpreter. +pub const TestResult = struct { + /// The test name (the `test "..."` label), report-arena-owned. + name: []const u8, + status: TestStatus, + /// Wall-clock duration of the body run in nanoseconds (0 for a skipped test). + duration_ns: u64 = 0, + /// Failure message (`.failed`) or skip reason (`.skipped`); null for a pass. + /// Report-arena-owned. + message: ?[]const u8 = null, + /// Source span of the failing statement (byte offsets into the test's file). + /// Non-null only for `.failed`; the shim resolves it to `file:line`. + span: ?SourceSpan = null, +}; + +/// Aggregate result of running a compilation set's tests. Holds its own arena; +/// `deinit` frees every owned string and the results slice at once. +pub const RunReport = struct { + arena: std.heap.ArenaAllocator, + results: []TestResult = &.{}, + passed: u32 = 0, + failed: u32 = 0, + skipped: u32 = 0, + + pub fn deinit(self: *RunReport) void { + self.arena.deinit(); + } +}; + +/// Run every `test` block in a type-checked `ast` (M1.0.15, §32). Declaration +/// order; a fresh World + Interpreter per test (full isolation, mono-world); +/// `@skip` reported skipped with its reason (body not run); `@only` focusing (if +/// any `@only` test exists in the set, only those run, the rest reported +/// skipped). `gpa` backs the transient per-test World/Interpreter; `io` provides +/// the monotonic clock for per-test wall-clock durations (Zig 0.16 clocks live +/// under `std.Io.Clock`); the returned report owns its strings. +pub fn run(gpa: std.mem.Allocator, io: Io, ast: *const Ast) !RunReport { + var report = RunReport{ .arena = std.heap.ArenaAllocator.init(gpa) }; + errdefer report.deinit(); + const a = report.arena.allocator(); + + var results: std.ArrayListUnmanaged(TestResult) = .empty; + + // `@only` is per compilation set: if ANY test is `@only`, only those run. + var any_only = false; + for (ast.test_decls.items) |decl| { + if (annotationPresent(ast, decl, .only)) { + any_only = true; + break; + } + } + + for (ast.test_decls.items) |decl| { + const name = try a.dupe(u8, ast.strings.slice(decl.name)); + + // `@skip` wins over everything: reported skipped, body not run. + if (skipReason(ast, decl)) |reason| { + try results.append(a, .{ .name = name, .status = .skipped, .message = try a.dupe(u8, reason) }); + report.skipped += 1; + continue; + } + // `@only` focusing: a non-`@only` test is skipped when any `@only` exists. + if (any_only and !annotationPresent(ast, decl, .only)) { + try results.append(a, .{ .name = name, .status = .skipped, .message = try a.dupe(u8, "not selected (@only in effect)") }); + report.skipped += 1; + continue; + } + + // Full isolation: a fresh World + Interpreter compiled from the shared + // AST (re-registers every declaration), bound for observer dispatch. + var world = World.init(); + defer world.deinit(gpa); + var interp = try Interpreter.compile(gpa, ast, &world); + defer interp.deinit(); + interp.io = io; // wall-clock provider for `measure { … }` (M1.0.15) + try interp.bindToWorld(&world); + + const t0 = Io.Clock.now(.awake, io); + const outcome = try interp.runTestBody(&world, decl); + const t1 = Io.Clock.now(.awake, io); + const dur: u64 = @intCast(@max(@as(i96, 0), t0.durationTo(t1).nanoseconds)); + + switch (outcome) { + .pass => { + try results.append(a, .{ .name = name, .status = .passed, .duration_ns = dur }); + report.passed += 1; + }, + .fail => |f| { + // Copy the borrowed message into report memory BEFORE the next + // test's interpreter overwrites it (string-discipline, §32 trap). + try results.append(a, .{ + .name = name, + .status = .failed, + .duration_ns = dur, + .message = try a.dupe(u8, f.message), + .span = f.span, + }); + report.failed += 1; + }, + } + } + + report.results = try results.toOwnedSlice(a); + return report; +} + +/// True iff `decl` carries an annotation of the given `kind`. +fn annotationPresent(ast: *const Ast, decl: ast_mod.TestDecl, kind: AnnotationKind) bool { + var i: u32 = 0; + while (i < decl.annotations_len) : (i += 1) { + if (ast.annot_pool.items[decl.annotations_extra + i].kind == kind) return true; + } + return false; +} + +/// The `@skip(reason: "...")` reason if the test is skipped, else null. The +/// type-checker (`checkTestAnnotations`) validated the arg shape (exactly one +/// string literal), so this reads it directly; a malformed arg (already +/// diagnosed) yields an empty reason rather than a crash. +fn skipReason(ast: *const Ast, decl: ast_mod.TestDecl) ?[]const u8 { + var i: u32 = 0; + while (i < decl.annotations_len) : (i += 1) { + const annot = ast.annot_pool.items[decl.annotations_extra + i]; + if (annot.kind != .skip) continue; + if (annot.args_len == 0) return ""; + const arg = ast.annot_args.items[annot.args_start]; + if (ast.exprKind(arg.value) != .string_lit) return ""; + return ast.strings.slice(ast.exprData(arg.value)); + } + return null; +} + +// ─── tests ────────────────────────────────────────────────────────────────── + +const parser_mod = @import("parser.zig"); +const types_mod = @import("types.zig"); +const Diagnostic = @import("diagnostics.zig").Diagnostic; + +/// Parse + type-check `source` (asserting both clean) then run its tests. A +/// throwaway `std.Io.Threaded` supplies the clock (the shim passes the process +/// `io`). +fn runSource(gpa: std.mem.Allocator, source: []const u8) !RunReport { + var pr = try parser_mod.parse(gpa, source); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + + var threaded: Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + return try run(gpa, threaded.io(), &pr.ast); +} + +test "passing test reports pass" { + const gpa = std.testing.allocator; + var report = try runSource(gpa, + \\test "arithmetic holds" { + \\ assert(1 + 1 == 2) + \\} + ); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 1), report.passed); + try std.testing.expectEqual(@as(u32, 0), report.failed); + try std.testing.expectEqual(@as(usize, 1), report.results.len); + try std.testing.expectEqual(TestStatus.passed, report.results[0].status); + try std.testing.expectEqualStrings("arithmetic holds", report.results[0].name); +} + +test "failing assert reports name, message, and span" { + const gpa = std.testing.allocator; + var report = try runSource(gpa, + \\test "two is not one" { + \\ assert(2 == 1, "two must equal one") + \\} + ); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 0), report.passed); + try std.testing.expectEqual(@as(u32, 1), report.failed); + const r = report.results[0]; + try std.testing.expectEqual(TestStatus.failed, r.status); + try std.testing.expectEqualStrings("two is not one", r.name); + try std.testing.expectEqualStrings("two must equal one", r.message.?); + try std.testing.expect(r.span != null); + try std.testing.expect(r.span.?.byte_end > r.span.?.byte_start); +} + +test "skip reports skipped with reason, body not run" { + const gpa = std.testing.allocator; + // The body would fail if run — @skip must prevent that. + var report = try runSource(gpa, + \\@skip(reason: "WIP") + \\test "unfinished" { + \\ assert(false) + \\} + ); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 0), report.failed); + try std.testing.expectEqual(@as(u32, 1), report.skipped); + try std.testing.expectEqual(TestStatus.skipped, report.results[0].status); + try std.testing.expectEqualStrings("WIP", report.results[0].message.?); +} + +test "only focuses execution to the annotated test" { + const gpa = std.testing.allocator; + var report = try runSource(gpa, + \\@only + \\test "focused" { + \\ assert(true) + \\} + \\test "ignored" { + \\ assert(false) + \\} + ); + defer report.deinit(); + // Only the @only test runs (passes); the other is skipped, never failing. + try std.testing.expectEqual(@as(u32, 1), report.passed); + try std.testing.expectEqual(@as(u32, 0), report.failed); + try std.testing.expectEqual(@as(u32, 1), report.skipped); + try std.testing.expectEqual(TestStatus.passed, report.results[0].status); + try std.testing.expectEqual(TestStatus.skipped, report.results[1].status); +} + +test "aggregate counts across a mixed set" { + const gpa = std.testing.allocator; + var report = try runSource(gpa, + \\test "p" { assert(true) } + \\test "f" { assert(false) } + \\@skip(reason: "later") + \\test "s" { assert(false) } + ); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 1), report.passed); + try std.testing.expectEqual(@as(u32, 1), report.failed); + try std.testing.expectEqual(@as(u32, 1), report.skipped); + try std.testing.expectEqual(@as(usize, 3), report.results.len); +} + +// ─── E3: test-world surface ────────────────────────────────────────────────── + +test "spawn_with returns a live handle usable with get(C) immediately" { + const gpa = std.testing.allocator; + var report = try runSource(gpa, + \\component Health { current: float = 100.0, max: float = 100.0 } + \\test "live handle" { + \\ let world = test_world() + \\ let e = world.spawn_with([Health { current: 42.0 }]) + \\ assert(e.get(Health).current == 42.0) + \\ assert(e.get(Health).max == 100.0) + \\} + ); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 1), report.passed); + try std.testing.expectEqual(@as(u32, 0), report.failed); +} + +test "spawn_with fires observers (on_added emits, event tallied after tick)" { + const gpa = std.testing.allocator; + // spawn_with fires the on_added(Marker) observer, which emits Spawned; the + // pre-tick event survives into tick(1), where @on_event(Spawned) tallies it. + var report = try runSource(gpa, + \\component Marker { x: int = 0 } + \\event Spawned { by: int } + \\resource Count { n: int = 0 } + \\@on_added(Marker) + \\rule note(entity: Entity, value: Marker) { + \\ emit Spawned { by: value.x } + \\} + \\@on_event(Spawned) + \\rule tally() + \\ when resource Count + \\{ + \\ get_mut(Count).n += 1 + \\} + \\test "observer effect visible" { + \\ let world = test_world() + \\ world.spawn_with([Marker { x: 7 }]) + \\ world.tick(1) + \\ assert(get(Count).n == 1) + \\} + ); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 1), report.passed); + try std.testing.expectEqual(@as(u32, 0), report.failed); +} + +test "tick drives an iterative rule" { + const gpa = std.testing.allocator; + var report = try runSource(gpa, + \\component Health { current: float = 0.0, max: float = 100.0 } + \\rule regen(entity: Entity) + \\ when entity has Health + \\{ + \\ entity.get_mut(Health).current += 10.0 + \\} + \\test "regen over ticks" { + \\ let world = test_world() + \\ let e = world.spawn_with([Health { current: 0.0 }]) + \\ world.tick(1) + \\ assert(e.get(Health).current == 10.0) + \\ world.tick(2) + \\ assert(e.get(Health).current == 30.0) + \\} + ); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 1), report.passed); + try std.testing.expectEqual(@as(u32, 0), report.failed); +} + +test "world.emit + tick drives an event-handling rule" { + const gpa = std.testing.allocator; + var report = try runSource(gpa, + \\event Damage { amount: int } + \\resource Tally { total: int = 0 } + \\@on_event(Damage) + \\rule absorb() + \\ when resource Tally + \\{ + \\ get_mut(Tally).total += event.amount + \\} + \\test "damage tallied" { + \\ let world = test_world() + \\ world.emit(Damage { amount: 30 }) + \\ world.tick(1) + \\ assert(get(Tally).total == 30) + \\} + ); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 1), report.passed); + try std.testing.expectEqual(@as(u32, 0), report.failed); +} + +test "fresh world per test — no cross-test state leak" { + const gpa = std.testing.allocator; + // A rule counts Marker entities into a resource each tick; a fresh World per + // test means test 2 sees only its own spawn, not test 1's two. + var report = try runSource(gpa, + \\resource Count { n: int = 0 } + \\component Marker { x: int = 0 } + \\rule count(entity: Entity) + \\ when entity has Marker and resource Count + \\{ + \\ get_mut(Count).n += 1 + \\} + \\test "first spawns two" { + \\ let world = test_world() + \\ world.spawn_with([Marker { x: 1 }]) + \\ world.spawn_with([Marker { x: 2 }]) + \\ world.tick(1) + \\ assert(get(Count).n == 2) + \\} + \\test "second spawns one — isolated" { + \\ let world = test_world() + \\ world.spawn_with([Marker { x: 9 }]) + \\ world.tick(1) + \\ assert(get(Count).n == 1) + \\} + ); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 2), report.passed); + try std.testing.expectEqual(@as(u32, 0), report.failed); +} + +test "test-body collection handle survives tick (no reset-from-under)" { + const gpa = std.testing.allocator; + // The driven rule body allocates + resets its own collection each tick; the + // test body's `xs` (a shared-store handle) must survive that tick unmangled. + // Without the suppression fix, the rule's per-body reset truncates the store + // and `xs`'s index is stale/reused → wrong values or OOB. + var report = try runSource(gpa, + \\component Marker { x: int = 0 } + \\rule churn(entity: Entity) + \\ when entity has Marker + \\{ + \\ let mut tmp: int[] = [7, 8, 9] + \\ tmp.push(tmp.len()) + \\} + \\test "xs survives tick" { + \\ let world = test_world() + \\ world.spawn_with([Marker { x: 1 }]) + \\ let mut xs: int[] = [10, 20] + \\ world.tick(1) + \\ xs.push(30) + \\ assert(xs.len() == 3) + \\ assert(xs[0] == 10) + \\ assert(xs[2] == 30) + \\} + ); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 1), report.passed); + try std.testing.expectEqual(@as(u32, 0), report.failed); +} + +test "test-body runtime string survives tick (no use-after-free)" { + const gpa = std.testing.allocator; + // `greeting` is a `.string_run` (concat) held across tick(1); the driven rule + // body's `resetRunStrings` would free it (the M1.0.14 UAF class) without the + // suppression fix. Under testing.allocator a freed/reused slot yields a wrong + // length (or OOB), so `len() == 10` is a genuine regression guard. + var report = try runSource(gpa, + \\component Marker { x: int = 0 } + \\rule churn(entity: Entity) + \\ when entity has Marker + \\{ + \\ let s = "ab" + "cd" + \\ s.len() + \\} + \\test "greeting survives tick" { + \\ let world = test_world() + \\ world.spawn_with([Marker { x: 1 }]) + \\ let name = "hero" + \\ let greeting = "hello " + name + \\ world.tick(1) + \\ assert(greeting.len() == 10) + \\} + ); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 1), report.passed); + try std.testing.expectEqual(@as(u32, 0), report.failed); +} + +// ─── E4: assertion family, measure, tick_until ────────────────────────────── + +test "assert_eq passes; a failure carries both values" { + const gpa = std.testing.allocator; + var report = try runSource(gpa, + \\test "eq ok" { assert_eq(2 + 2, 4) } + \\test "eq bad" { assert_eq(2 + 2, 5) } + ); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 1), report.passed); + try std.testing.expectEqual(@as(u32, 1), report.failed); + // The failing test's message names both compared values (4 and 5). + var msg: []const u8 = ""; + for (report.results) |r| { + if (r.status == .failed) msg = r.message.?; + } + try std.testing.expect(std.mem.indexOf(u8, msg, "4") != null); + try std.testing.expect(std.mem.indexOf(u8, msg, "5") != null); +} + +test "assert_neq / assert_approx / assert_some / assert_none" { + const gpa = std.testing.allocator; + var report = try runSource(gpa, + \\test "neq ok" { assert_neq(1, 2) } + \\test "neq bad" { assert_neq(3, 3) } + \\test "approx ok" { assert_approx(1.0, 1.0000001) } + \\test "approx bad" { assert_approx(1.0, 2.0) } + \\test "some ok" { let s: int? = some(5) assert_some(s) } + \\test "some bad" { let n: int? = none assert_some(n) } + \\test "none ok" { let n: int? = none assert_none(n) } + \\test "none bad" { let s: int? = some(5) assert_none(s) } + ); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 4), report.passed); + try std.testing.expectEqual(@as(u32, 4), report.failed); +} + +test "panic / todo / unreachable fail the test with a message" { + const gpa = std.testing.allocator; + var report = try runSource(gpa, + \\test "boom" { panic("kaboom") } + \\test "wip" { todo() } + \\test "never" { unreachable() } + ); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 0), report.passed); + try std.testing.expectEqual(@as(u32, 3), report.failed); + for (report.results) |r| { + try std.testing.expect(r.message != null); + try std.testing.expect(r.message.?.len > 0); + } + // The custom panic message is preserved. + try std.testing.expectEqualStrings("kaboom", report.results[0].message.?); +} + +test "measure returns a positive duration" { + const gpa = std.testing.allocator; + var report = try runSource(gpa, + \\test "measured" { + \\ let elapsed = measure { + \\ let mut i: int = 0 + \\ while i < 5000 { i += 1 } + \\ } + \\ assert(elapsed > 0.0s) + \\ assert(elapsed < 100.0s) + \\} + ); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 1), report.passed); + try std.testing.expectEqual(@as(u32, 0), report.failed); +} + +test "tick_until stops on the predicate and on timeout" { + const gpa = std.testing.allocator; + var report = try runSource(gpa, + \\resource Counter { n: int = 0 } + \\rule inc() + \\ when resource Counter + \\{ + \\ get_mut(Counter).n += 1 + \\} + \\test "reaches predicate" { + \\ let world = test_world() + \\ let hit = tick_until(|| get(Counter).n >= 3, 1.0s) + \\ assert(hit) + \\ assert_eq(get(Counter).n, 3) + \\} + \\test "hits timeout" { + \\ let world = test_world() + \\ let hit = tick_until(|| get(Counter).n >= 1000, 0.05s) + \\ assert(not hit) + \\} + ); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 2), report.passed); + try std.testing.expectEqual(@as(u32, 0), report.failed); +} + +// ─── E4 review-fix regressions ────────────────────────────────────────────── + +test "a rule-body assert failure during tick does not leak into the test's message" { + const gpa = std.testing.allocator; + // A driven rule fails an assert (harvested); the test body then fails its own + // plain assert. The reported message must be the test's ("assertion failed"), + // NOT the stale rule assert ("assert_eq: 0 != 999"). + var report = try runSource(gpa, + \\resource C { n: int = 0 } + \\rule bad() + \\ when resource C + \\{ + \\ assert_eq(get(C).n, 999) + \\} + \\test "no stale message" { + \\ let world = test_world() + \\ world.tick(1) + \\ assert(1 == 2) + \\} + ); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 1), report.failed); + try std.testing.expect(std.mem.indexOf(u8, report.results[0].message.?, "999") == null); +} + +test "assert_eq / assert_neq compare strings by bytes (no false-pass)" { + const gpa = std.testing.allocator; + var report = try runSource(gpa, + \\test "eq computed strings" { let a = "hi" assert_eq(a + "!", "hi!") } + \\test "neq equal strings fails" { let a = "hi" assert_neq(a + "!", "hi!") } + ); + defer report.deinit(); + // eq of two byte-equal strings passes; neq of two byte-equal strings FAILS + // (without the fix, Value.eql's string-blindness would flip both). + try std.testing.expectEqual(@as(u32, 1), report.passed); + try std.testing.expectEqual(@as(u32, 1), report.failed); +} + +test "a throwing tick_until predicate fails the test with the throw" { + const gpa = std.testing.allocator; + var report = try runSource(gpa, + \\test "pred throws" { + \\ let world = test_world() + \\ let hit = tick_until(|| { throw Error { message: "boom", code: .io_fail } }, 1.0s) + \\ assert(hit) + \\} + ); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 1), report.failed); + // The failure is the predicate's uncaught throw, surfaced immediately — + // not a timeout / a downstream assert. + try std.testing.expectEqualStrings("uncaught throw", report.results[0].message.?); +} diff --git a/src/etch/token.zig b/src/etch/token.zig index daae57c2..3135aec5 100644 --- a/src/etch/token.zig +++ b/src/etch/token.zig @@ -109,12 +109,13 @@ pub const TokenKind = enum { kw_import, // import directive (M1.0.7 cross-file import — module path + optional alias / selective items; graduated from non_s3_keywords) kw_const, // top-level `const` declaration (M1.0.8 — graduated from non_s3_keywords; top-level only per part1 §4.5) kw_private, // `private` visibility modifier prefix on a declaration_body (M1.0.8 — graduated from non_s3_keywords; grammar §5.1) - kw_test, // top-level `test "name" { ... }` block (M1.0.8 — graduated from non_s3_keywords; parse + validate only, no execution) + kw_test, // top-level `test "name" { ... }` block (M1.0.8 graduated it; M1.0.15 executes it end-to-end via the test runner) kw_spawn, // structural spawn expr `spawn(C{…})` (M1.0.10, §3.2 structural_spawn) + the async task statement `[let IDENT =] spawn { }` (M1.0.12, §4.2 spawn_stmt) — disambiguated by the next token kw_race, // race statement `race { race_branch* }` (M1.0.12 — graduated from non_s3_keywords; §4.2 race_stmt) kw_sync, // sync statement `sync { sync_branch* }` (M1.0.12 — graduated from non_s3_keywords; §4.2 sync_stmt) kw_every, // repeating timer statement `[let IDENT =] every(d) { }` (M1.0.13 — graduated from non_s3_keywords; §4.3 timer_stmt) kw_after_unscaled, // unscaled one-shot timer statement `[let IDENT =] after_unscaled(d) { }` (M1.0.13 — graduated from non_s3_keywords; §4.3 timer_stmt) + kw_measure, // `measure { block }` expression (M1.0.15 — §17 erratum; wall-clock Duration, test-body only via E0910). Stays inside the [kw_let, kw_f64] keyword range for isKeywordToken. // ── Primitive type keywords (lexed as kw_type_*) ── kw_int, @@ -287,6 +288,7 @@ pub const s3_keywords = [_]KeywordEntry{ .{ .lexeme = "sync", .kind = .kw_sync }, .{ .lexeme = "every", .kind = .kw_every }, .{ .lexeme = "after_unscaled", .kind = .kw_after_unscaled }, + .{ .lexeme = "measure", .kind = .kw_measure }, .{ .lexeme = "true", .kind = .bool_literal }, .{ .lexeme = "false", .kind = .bool_literal }, .{ .lexeme = "int", .kind = .kw_int }, diff --git a/src/etch/types.zig b/src/etch/types.zig index e6112f83..9d5a7137 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -211,6 +211,10 @@ pub const ResolvedType = union(enum) { /// (the optional-ness is not tracked; permissive). `if let` / `while let` /// unwrap this to the payload builtin. optional: BuiltinType, + /// The current test's World handle (M1.0.15) — the return type of + /// `test_world()` (test bodies only). Receiver of `spawn_with`/`emit`/`tick` + /// in `dispatchMethodOnType`. No payload (mono-world). Not field-storable. + test_world, /// Type unknown / unresolved. Used as the fallback after a diagnostic /// has been emitted; subsequent checks treat `unknown` as wildcard to /// avoid cascade errors. @@ -233,6 +237,7 @@ pub const ResolvedType = union(enum) { .event_t => |id| id == b.event_t, .generic => |id| id == b.generic, .optional => |bt| bt == b.optional, + .test_world => true, .unknown => true, }; } @@ -250,7 +255,7 @@ pub const ResolvedType = union(enum) { }; /// Symbol entry in the file-local symbol table built by pass 1. -pub const SymbolKind = enum { component, resource, rule, type_alias, fn_, struct_, enum_, trait_, event_, data_, routine_, behavior_, quest_, dialogue_, ability_, motion_, widget_, locale_, effect_, audio_graph_, sequence_, anim_graph_, shader_, const_, test_ }; +pub const SymbolKind = enum { component, resource, rule, type_alias, fn_, struct_, enum_, trait_, event_, data_, routine_, behavior_, quest_, dialogue_, ability_, motion_, widget_, locale_, effect_, audio_graph_, sequence_, anim_graph_, shader_, const_ }; const Symbol = struct { kind: SymbolKind, @@ -329,7 +334,7 @@ fn numericLitValue(arena: *const AstArena, expr_id: NodeId) ?f64 { /// (M0.8 E3); other construct targets arrive with their constructs. /// `data` / `routine` join with the E4 Level-B constructs (no builtin /// annotation targets them — only `.custom` is accepted, like `function`). -const AnnotTarget = enum { component, resource, rule, field, function, event, data, routine, behavior, quest, dialogue, ability, theme, motion, input_mapping, widget, locale, effect, audio_graph, audio_score, sequence, anim_graph, shader, scene, prefab }; +const AnnotTarget = enum { component, resource, rule, field, function, event, data, routine, behavior, quest, dialogue, ability, theme, motion, input_mapping, widget, locale, effect, audio_graph, audio_score, sequence, anim_graph, shader, scene, prefab, test_ }; /// Whether a builtin annotation kind is valid on `target` /// (cf. `etch-resolver-types.md` §13.2 + `etch-reference-part3.md` §1-§10). @@ -354,6 +359,10 @@ fn annotationAppliesTo(kind: ast_mod.AnnotationKind, target: AnnotTarget) bool { // Entity-typed + uniqueness are enforced at the declaration (E0502). .entity_target => target == .field, .shader_fn => target == .function, + // M1.0.15 — the three `test`-only annotations (§17). Applied to any + // other target → E0502 (misapplied); applied to a test, arg-validated + // separately in `checkTestAnnotations`. + .tag, .skip, .only => target == .test_, .loc => false, }; } @@ -367,6 +376,12 @@ pub const TypeChecker = struct { diagnostics: *std.ArrayListUnmanaged(Diagnostic), /// Symbol table keyed by interned name `StringId`. symbols: std.AutoHashMapUnmanaged(StringId, Symbol) = .empty, + /// Test-name namespace (M1.0.15), SEPARATE from `symbols`: a `test "Foo"` + /// no longer collides with a `component Foo` (the M1.0.8 collision this + /// milestone resolves). Keyed by the interned test-name string; membership + /// enforces intra-namespace uniqueness (two `test "X"` → E0101, contextual + /// message). Test names are never referenced by code and never exported. + test_symbols: std.AutoHashMapUnmanaged(StringId, void) = .empty, /// Inherent `impl` methods (M0.8 E2 block 3), keyed by `methodKey(type_name, /// method_name)` → index into `arena.impl_methods`. Drives the inherent /// (kind 1, `etch-resolver-types.md §5.1`) dispatch of `recv.method()` and @@ -406,6 +421,12 @@ pub const TypeChecker = struct { /// `async fn`/`async method`, in a NON-async context is E0901 /// `AsyncCallInNonAsyncContext`. `false` outside any body. current_is_async: bool = false, + /// Whether the statements currently being checked are a `test` block body + /// (M1.0.15). Test-scoped builtins (`test_world`, `tick_until`) and the + /// `measure { }` expression resolve only when this is `true`; outside a test + /// body they fall through to E0102 (`test_world`/`tick_until`) or E0910 + /// (`measure`). Set/restored around the body in `checkTest`. + in_test_body: bool = false, /// The kind of the INNERMOST `race`/`sync` branch or `branch`/`spawn` body /// enclosing the statements being checked (M1.0.12 E3), `null` outside any. /// Drives E0906 (a `return` is legal only in a `race` branch — @@ -525,6 +546,7 @@ pub const TypeChecker = struct { pub fn deinit(self: *TypeChecker) void { self.symbols.deinit(self.gpa); + self.test_symbols.deinit(self.gpa); self.methods.deinit(self.gpa); self.trait_impls.deinit(self.gpa); self.conc_labels.deinit(self.gpa); @@ -2925,12 +2947,20 @@ pub const TypeChecker = struct { try self.checkConstValue(decl.value, decl.type_node); }, .test_decl => { - // Top-level `test` block (M1.0.8). Register a `test_` symbol - // (tracked, NOT exported — `buildExports` omits it). M1.0.8 - // is parse + validate + registration only; the body is not - // executed (no runtime surface — M1.0.9). + // Top-level `test` block (M1.0.15). The test name lives in a + // DEDICATED namespace (`test_symbols`), so `test "Foo"` does + // not collide with `component Foo` (the M1.0.8 collision this + // milestone resolves). Intra-namespace uniqueness only: two + // `test "X"` → E0101 with a contextual message (the code is + // reused per `etch-diagnostics.md` §24.1, the + // `collectImplMethods` precedent). Never exported. const decl = self.arena.test_decls.items[data]; - try self.registerSymbol(.test_, decl.name, item_id, span); + const gop = try self.test_symbols.getOrPut(self.gpa, decl.name); + if (gop.found_existing) { + try self.emit(.duplicate_symbol, .error_, span, "duplicate test '{s}'", .{self.arena.strings.slice(decl.name)}); + } + try self.validateAnnotations(decl.annotations_extra, decl.annotations_len, .test_); + try self.checkTestAnnotations(decl.annotations_extra, decl.annotations_len); }, .data_decl => { // A `data` table (M0.8 E4 Level B) registers its name; the @@ -3657,6 +3687,7 @@ pub const TypeChecker = struct { .rule_decl => try self.checkRule(self.arena.rule_decls.items[data]), .fn_decl => try self.checkFn(self.arena.fn_decls.items[data]), .impl_decl => try self.checkImpl(self.arena.impl_decls.items[data]), + .test_decl => try self.checkTest(self.arena.test_decls.items[data]), else => {}, } } @@ -3953,6 +3984,71 @@ pub const TypeChecker = struct { } } + /// Type-check a `test` block body (M1.0.15, §32 normative). The body is a + /// SYNC statement context: `await` inside it → E0901 (function coloring, + /// `current_is_async = false`); the async surface is exercised through the + /// file's rules driven by `tick(n)`. `in_test_body` unlocks the test-scoped + /// builtins (`test_world`/`tick_until`) and the `measure { }` expression for + /// the duration of the body. Mirrors `checkTimerBodyRun`'s sync-context + /// discipline; the body is a single `block_expr`, checked via `synthExprE`. + fn checkTest(self: *TypeChecker, decl: ast_mod.TestDecl) !void { + // A test body has no `when` clause but full ECS access (`get`/`get_mut` + // on any resource/component, e.g. `world.spawn_with(...).get(Health)`) — + // the same unrestricted context observer / data bodies use. + var ctx: RuleCtx = .{ .unrestricted_ecs_access = true }; + defer ctx.deinit(self.gpa); + + const saved_async = self.current_is_async; + const saved_susp = self.await_suspendable; + const saved_test = self.in_test_body; + self.current_is_async = false; + self.await_suspendable = true; + self.in_test_body = true; + defer { + self.current_is_async = saved_async; + self.await_suspendable = saved_susp; + self.in_test_body = saved_test; + } + _ = try self.synthExprE(decl.body, &ctx); + } + + /// Validate the argument shape of the three `test`-only annotations (M1.0.15, + /// §17). Applicability (test-only) is already enforced by + /// `validateAnnotations` with the `.test_` target; this checks the args, + /// reusing E0502 with a contextual message (no new diagnostic code — E0910 is + /// the milestone's only new code): + /// - `@tag(.X)`: exactly one enum-shorthand arg in {unit, integration, + /// slow, perf}. + /// - `@skip(reason: "...")`: exactly one string-literal arg (positional or + /// named `reason`). + /// - `@only`: no args. + fn checkTestAnnotations(self: *TypeChecker, start: u32, len: u32) !void { + var i: u32 = 0; + while (i < len) : (i += 1) { + const annot = self.arena.annot_pool.items[start + i]; + switch (annot.kind) { + .tag => { + const ok = annot.args_len == 1 and blk: { + const arg = self.arena.annot_args.items[annot.args_start]; + if (self.arena.exprKind(arg.value) != .tag_path) break :blk false; + const v = self.arena.strings.slice(self.arena.exprData(arg.value)); + break :blk std.mem.eql(u8, v, "unit") or std.mem.eql(u8, v, "integration") or + std.mem.eql(u8, v, "slow") or std.mem.eql(u8, v, "perf"); + }; + if (!ok) try self.emit(.annotation_misapplied, .error_, annot.span, "@tag requires one of .unit / .integration / .slow / .perf", .{}); + }, + .skip => { + const ok = annot.args_len == 1 and self.arena.exprKind(self.arena.annot_args.items[annot.args_start].value) == .string_lit; + if (!ok) try self.emit(.annotation_misapplied, .error_, annot.span, "@skip requires a string reason (e.g. @skip(reason: \"WIP\"))", .{}); + }, + .only => { + if (annot.args_len != 0) try self.emit(.annotation_misapplied, .error_, annot.span, "@only takes no arguments", .{}); + }, + else => {}, + } + } + } + /// Type-check a top-level `fn` body (M0.8 E2 call mechanism). Binds params /// as locals (no `when` clause / no entity context), records the declared /// return type for `return` / trailing-value checks, walks the body run, @@ -5030,6 +5126,7 @@ pub const TypeChecker = struct { .method_call => return try self.synthMethodCall(id, data, ctx_opt), .loop_expr => return try self.synthLoop(data, ctx_opt), .block_expr => return try self.synthBlock(data, ctx_opt), + .measure_expr => return try self.synthMeasure(id, data, ctx_opt), .if_expr => return try self.synthIf(id, data, ctx_opt), // Reaching a structural spawn through `synthExpr` means it is being // used as a VALUE (let binding, field receiver, call argument) — the @@ -5253,6 +5350,27 @@ pub const TypeChecker = struct { return try self.synthExprE(blk.value, ctx_opt); } + /// Type a `measure { block }` expression (M1.0.15, §17 erratum). Result type + /// is always `Duration` (the elapsed wall-clock; the block's own value is + /// discarded). Legal ONLY inside a test body — anywhere else it is + /// `E0910 MeasureOutsideTest` (wall-clock never enters deterministic gameplay + /// code). The block statements are checked in the current (sync test) context. + fn synthMeasure(self: *TypeChecker, id: NodeId, data: u32, ctx_opt: ?*RuleCtx) TypeError!ResolvedType { + if (!self.in_test_body) { + try self.emit(.measure_outside_test, .error_, self.arena.exprSpan(id), "measure {{ … }} is only valid inside a test body", .{}); + } + const m = self.arena.measure_exprs.items[data]; + if (ctx_opt) |ctx| { + var i: u32 = 0; + while (i < m.body_len) : (i += 1) { + const stmt: NodeId = @bitCast(self.arena.extra.items[m.body_start + i]); + try self.checkStmt(ctx, stmt); + } + } + if (!m.value.isNone()) _ = try self.synthExprE(m.value, ctx_opt); + return .{ .builtin = .duration }; + } + /// Type an `if` expression (M0.8 control flow). The condition must be /// `bool`; the then / else branches (block expressions, `else if` chaining /// through a nested `if`) must unify to one result type. An `if` with no @@ -5303,6 +5421,132 @@ pub const TypeChecker = struct { }; } + fn synthArg(self: *TypeChecker, call: ast_mod.CallExpr, i: u32, ctx_opt: ?*RuleCtx) TypeError!ResolvedType { + const arg: NodeId = @bitCast(self.arena.extra.items[call.args_start + i]); + return try self.synthExprE(arg, ctx_opt); + } + + /// Whether a type has a meaningful runtime equality for `assert_eq`/`assert_neq` + /// (M1.0.15): scalars/strings (`.builtin`, incl. `string_`/`Entity`), enums, + /// or `unknown` (already-diagnosed). Aggregates lack a structural `Value.eql`. + fn assertComparable(t: ResolvedType) bool { + return switch (t) { + .builtin, .enum_t, .unknown => true, + else => false, + }; + } + + /// Whether a type is a float (or `unknown`, already-diagnosed) — the operand + /// constraint for `assert_approx` (M1.0.15): a tolerance comparison is + /// float-only, so int operands are rejected at type-check (symmetry with the + /// rest of the assert family) rather than failing loud at runtime. + fn isFloatType(t: ResolvedType) bool { + return t == .unknown or (t == .builtin and t.builtin.isFloat()); + } + + /// Resolve a builtin free-function call by name (M1.0.15). Returns the result + /// type when `name` is a builtin, else `null` (the caller falls through to the + /// user-fn lookup, then E0102). Global builtins — the assertion family + /// (`assert_eq`/`assert_neq`/`assert_approx`/`assert_some`/`assert_none`, + /// `etch-stdlib.md §19.4`) + `panic`/`todo`/`unreachable` — resolve anywhere; + /// test-scoped builtins (`test_world`, `tick_until`) only inside a test body. + /// Statement-effect builtins yield `unknown` (≈ unit); args are synth-checked + /// so nested expressions are typed and light shape checks surface misuse. + fn synthBuiltinCall(self: *TypeChecker, id: NodeId, call: ast_mod.CallExpr, callee_name: StringId, ctx_opt: ?*RuleCtx) TypeError!?ResolvedType { + const name = self.arena.strings.slice(callee_name); + const span = self.arena.exprSpan(id); + + // ── Test-scoped (only inside a test body; else null → E0102) ── + if (self.in_test_body) { + if (std.mem.eql(u8, name, "test_world")) { + if (call.args_len != 0) try self.emit(.arg_count_mismatch, .error_, span, "test_world() takes no arguments", .{}); + return ResolvedType.test_world; + } + if (std.mem.eql(u8, name, "tick_until")) { + // `tick_until(pred: () -> bool, timeout: Duration) -> bool`. + if (call.args_len != 2) { + try self.emit(.arg_count_mismatch, .error_, span, "tick_until(pred: () -> bool, timeout: Duration) takes 2 arguments", .{}); + } else { + _ = try self.synthArg(call, 0, ctx_opt); + const t1 = try self.synthArg(call, 1, ctx_opt); + if (t1 == .builtin and t1.builtin != .duration) { + try self.emit(.type_mismatch, .error_, span, "tick_until timeout must be a Duration", .{}); + } + } + return .{ .builtin = .bool_ }; + } + } + + // ── Global assertion family + panic/todo/unreachable ── + if (std.mem.eql(u8, name, "assert_eq") or std.mem.eql(u8, name, "assert_neq")) { + if (call.args_len < 2 or call.args_len > 3) { + try self.emit(.arg_count_mismatch, .error_, span, "{s}(a, b[, msg]) takes 2 or 3 arguments", .{name}); + } else { + const ta = try self.synthArg(call, 0, ctx_opt); + const tb = try self.synthArg(call, 1, ctx_opt); + // Only scalar / enum / string values compare at runtime (M1.0.15 + // review fix): aggregates (struct/array/map/set/optional/closure) + // have no structural `Value.eql`, so an aggregate operand would + // false-fail `assert_eq` and false-PASS `assert_neq`. Reject them + // fail-loud rather than mis-compare. + if (!assertComparable(ta) or !assertComparable(tb)) { + try self.emit(.type_mismatch, .error_, span, "{s} compares scalar / string / enum values (structs, arrays, maps, sets, optionals are not comparable in v0.6)", .{name}); + } else if (ta == .builtin and tb == .builtin and ta.builtin != tb.builtin) { + try self.emit(.type_mismatch, .error_, span, "{s} compares two values of the same type", .{name}); + } + if (call.args_len == 3) _ = try self.synthArg(call, 2, ctx_opt); + } + return ResolvedType.unknown; + } + if (std.mem.eql(u8, name, "assert_approx")) { + if (call.args_len < 2 or call.args_len > 4) { + try self.emit(.arg_count_mismatch, .error_, span, "assert_approx(a, b[, tolerance][, msg]) takes 2 to 4 arguments", .{}); + } else { + // Float-only by construction (tolerance comparison): constrain a, + // b, and the optional tolerance at type-check — symmetric with the + // rest of the family (the runtime keeps a defensive fail-loud). + const ta = try self.synthArg(call, 0, ctx_opt); + const tb = try self.synthArg(call, 1, ctx_opt); + if (!isFloatType(ta) or !isFloatType(tb)) { + try self.emit(.type_mismatch, .error_, span, "assert_approx compares float values (use assert_eq for other types)", .{}); + } + if (call.args_len >= 3) { + const tt = try self.synthArg(call, 2, ctx_opt); + if (!isFloatType(tt)) { + try self.emit(.type_mismatch, .error_, span, "assert_approx tolerance must be a float", .{}); + } + } + if (call.args_len == 4) _ = try self.synthArg(call, 3, ctx_opt); + } + return ResolvedType.unknown; + } + if (std.mem.eql(u8, name, "assert_some") or std.mem.eql(u8, name, "assert_none")) { + if (call.args_len < 1 or call.args_len > 2) { + try self.emit(.arg_count_mismatch, .error_, span, "{s}(value[, msg]) takes 1 or 2 arguments", .{name}); + } else { + const tv = try self.synthArg(call, 0, ctx_opt); + if (tv != .optional and tv != .unknown) { + try self.emit(.type_mismatch, .error_, span, "{s} requires an optional (T?) value", .{name}); + } + if (call.args_len == 2) _ = try self.synthArg(call, 1, ctx_opt); + } + return ResolvedType.unknown; + } + if (std.mem.eql(u8, name, "panic")) { + if (call.args_len != 1) { + try self.emit(.arg_count_mismatch, .error_, span, "panic(msg) takes 1 argument", .{}); + } else _ = try self.synthArg(call, 0, ctx_opt); + return ResolvedType.unknown; + } + if (std.mem.eql(u8, name, "todo") or std.mem.eql(u8, name, "unreachable")) { + if (call.args_len > 1) { + try self.emit(.arg_count_mismatch, .error_, span, "{s}([msg]) takes 0 or 1 arguments", .{name}); + } else if (call.args_len == 1) _ = try self.synthArg(call, 0, ctx_opt); + return ResolvedType.unknown; + } + return null; + } + /// Type a call expression (M0.8 closures). E1 only resolves calls whose /// callee is a closure: arity is checked, then the body is typed for the /// return with the parameters bound (to their annotation, else the argument @@ -5321,6 +5565,13 @@ pub const TypeChecker = struct { const callee_name = self.arena.exprData(call.callee); const shadowed = if (ctx_opt) |ctx| ctx.locals.contains(callee_name) else false; if (!shadowed) { + // Builtin free functions (M1.0.15) — the assertion family + + // panic/todo/unreachable (global), test_world/tick_until + // (test-scoped). Resolved before the user-fn lookup so a builtin + // name is never shadowed by a stdlib decl; a test-scoped builtin + // outside a test body returns null → falls through to E0102 + // (§32: "fall through to E0102"). + if (try self.synthBuiltinCall(id, call, callee_name, ctx_opt)) |t| return t; if (self.symbols.get(callee_name)) |sym| { if (sym.kind == .fn_) { return try self.synthFreeFnCall(id, call, sym.item_id, ctx_opt); @@ -5949,6 +6200,60 @@ pub const TypeChecker = struct { try self.checkComponentInstance(ci, .type_mismatch, .structural_component_field_unknown, .structural_component_field_type_invalid); } + /// M1.0.15 — validate `world.spawn_with([C {…}, …])`: exactly one array + /// literal whose every element is a component literal. Reuses + /// `checkStructuralComponentLiteral` (the same E0306/E0307 as scene / prefab / + /// structural `spawn`) — bespoke checking, NOT a general heterogeneous array + /// type (a component list is not a typed `T[]`). + fn checkSpawnWithArg(self: *TypeChecker, id: NodeId, mc: ast_mod.MethodCall, ctx_opt: ?*RuleCtx) TypeError!void { + _ = ctx_opt; + if (mc.args_len != 1) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "spawn_with takes exactly one component-literal list '[C {{ ... }}, ...]'", .{}); + return; + } + const arg: NodeId = @bitCast(self.arena.extra.items[mc.args_start]); + if (self.arena.exprKind(arg) != .array_lit) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "spawn_with expects a component-literal list '[C {{ ... }}, ...]'", .{}); + return; + } + const al = self.arena.array_lits.items[self.arena.exprData(arg)]; + if (al.is_fill) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "spawn_with does not accept fill syntax '[x; n]'", .{}); + return; + } + var i: u32 = 0; + while (i < al.elements_len) : (i += 1) { + const elem: NodeId = @bitCast(self.arena.extra.items[al.elements_start + i]); + try self.checkStructuralComponentLiteral(elem); + } + } + + /// M1.0.15 — validate `world.emit(T {…})`: exactly one event-literal arg + /// naming a declared `event`, fields checked via the shared + /// `checkEventFieldRun` (identical to the `emit` statement, §32). + fn checkWorldEmitArg(self: *TypeChecker, id: NodeId, mc: ast_mod.MethodCall, ctx_opt: ?*RuleCtx) TypeError!void { + if (mc.args_len != 1) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "emit takes exactly one event literal 'T {{ ... }}'", .{}); + return; + } + const arg: NodeId = @bitCast(self.arena.extra.items[mc.args_start]); + if (self.arena.exprKind(arg) != .struct_lit) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "emit expects an event literal 'T {{ ... }}'", .{}); + return; + } + const sl = self.arena.struct_lits.items[self.arena.exprData(arg)]; + if (sl.type_name == 0) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "emit needs an explicit event type ('T {{ ... }}')", .{}); + return; + } + const sym = self.symbols.get(sl.type_name); + if (sym == null or sym.?.kind != .event_) { + try self.emit(.undefined_symbol, .error_, self.arena.exprSpan(arg), "'{s}' is not a declared event", .{self.arena.strings.slice(sl.type_name)}); + return; + } + try self.checkEventFieldRun(self.arena.itemData(sym.?.item_id), sl.fields_start, sl.fields_len, ctx_opt); + } + /// M1.0.10 E2 — validate a type name used in a structural mutation /// (`entity.remove(T)` and the component-literal types of `spawn` / `add`) /// resolves to a declared `component`. Mirrors the `when has` component @@ -6131,6 +6436,37 @@ pub const TypeChecker = struct { return ResolvedType.unknown; } + // M1.0.15 — the test World surface (§32): methods on the handle returned + // by `test_world()`. `spawn_with([C {…}, …])` -> Entity (immediate spawn, + // fires on_spawn observers, live handle); `emit(T {…})` -> unit (emit- + // statement semantics); `tick(n)` -> unit (advance n ticks, n >= 1 + // enforced at runtime). Reachable only on `.test_world`, itself resolvable + // only in a test body. + if (recv_t == .test_world) { + if (std.mem.eql(u8, method_slice, "spawn_with")) { + try self.checkSpawnWithArg(id, mc, ctx_opt); + return ResolvedType{ .builtin = .entity }; + } + if (std.mem.eql(u8, method_slice, "emit")) { + try self.checkWorldEmitArg(id, mc, ctx_opt); + return ResolvedType.unknown; + } + if (std.mem.eql(u8, method_slice, "tick")) { + if (mc.args_len != 1) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "tick takes exactly one count 'tick(n)'", .{}); + } else { + const arg: NodeId = @bitCast(self.arena.extra.items[mc.args_start]); + const t = self.synthExpr(arg, ctx_opt); + if (t == .builtin and !t.builtin.isInteger()) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "tick(n) requires an integer tick count", .{}); + } + } + return ResolvedType.unknown; + } + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "no method '{s}' on the test world (spawn_with / emit / tick)", .{method_slice}); + return ResolvedType.unknown; + } + // M1.0.9 B2 — builtin extension methods on an `Entity` receiver, checked // BEFORE the trait-method resolution below (these are interpreter builtins, // not user traits; any other method on an Entity falls through to the @@ -6674,7 +7010,7 @@ pub const TypeChecker = struct { try self.emit(.invalid_field_filter, .error_, span, "field '{s}' does not exist on event '{s}'", .{ self.arena.strings.slice(field_name), self.arena.strings.slice(name_id) }); return ResolvedType.unknown; }, - .builtin, .range, .array_fixed, .array_dyn, .map_t, .set_t, .closure, .enum_t, .generic, .optional, .unknown => return ResolvedType.unknown, + .builtin, .range, .array_fixed, .array_dyn, .map_t, .set_t, .closure, .enum_t, .generic, .optional, .test_world, .unknown => return ResolvedType.unknown, } } @@ -6995,13 +7331,12 @@ test "well-formed top-level const checks clean" { try std.testing.expectEqual(@as(usize, 0), result.diagnostics.items.len); } -test "test block registered as test_ symbol and not exported" { +test "duplicate test names collide within the test namespace (E0101, M1.0.15)" { const gpa = std.testing.allocator; - // Registration proof: two top-level `test` blocks with the same name collide - // through `registerSymbol` → E0101 DuplicateSymbol (so a `test` block IS a - // registered symbol). The "not exported" half is exercised cross-file in - // tests/etch/import_resolve_test.zig (a test name is not in module exports → - // E0104 on import). + // Intra-namespace uniqueness: two top-level `test` blocks with the same name + // collide → E0101 (contextual "duplicate test 'X'"). The "not exported" half + // is exercised cross-file in tests/etch/import_resolve_test.zig (a test name + // is not in module exports → E0104 on import). var result = try parseAndCheck(gpa, \\test "same name" { } \\test "same name" { } @@ -7010,6 +7345,122 @@ test "test block registered as test_ symbol and not exported" { try expectAnyCode(result.diagnostics.items, .duplicate_symbol); } +test "test name does not collide with a component of the same name (M1.0.15)" { + const gpa = std.testing.allocator; + // The M1.0.8 collision this milestone resolves: a `test "Foo"` lives in a + // dedicated namespace, so it no longer clashes with `component Foo`. + var result = try parseAndCheck(gpa, + \\component Foo { x: int = 0 } + \\test "Foo" { } + ); + defer result.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), result.diagnostics.items.len); +} + +test "test annotation applicability + args (M1.0.15)" { + const gpa = std.testing.allocator; + + // `@phase` is rule-only — on a test it is misapplied (E0502). + var wrong = try parseAndCheck(gpa, + \\@phase(.update) + \\test "x" { } + ); + defer wrong.deinit(gpa); + try expectAnyCode(wrong.diagnostics.items, .annotation_misapplied); + + // The three test annotations with well-formed args check clean. + var ok = try parseAndCheck(gpa, + \\@tag(.perf) + \\test "a" { } + \\@skip(reason: "WIP") + \\test "b" { } + \\@only + \\test "c" { } + ); + defer ok.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), ok.diagnostics.items.len); + + // A bad `@tag` value (not in {unit, integration, slow, perf}) is E0502. + var bad_tag = try parseAndCheck(gpa, + \\@tag(.bogus) + \\test "d" { } + ); + defer bad_tag.deinit(gpa); + try expectAnyCode(bad_tag.diagnostics.items, .annotation_misapplied); +} + +test "await in a test body is E0901 (sync context, M1.0.15)" { + const gpa = std.testing.allocator; + // A `test` body is a sync context (§32): `await` there is a coloring error. + var result = try parseAndCheck(gpa, + \\test "no await here" { + \\ await wait(1.0s) + \\} + ); + defer result.deinit(gpa); + try expectAnyCode(result.diagnostics.items, .async_call_in_non_async_context); +} + +test "measure outside a test body is E0910; inside checks clean (M1.0.15)" { + const gpa = std.testing.allocator; + // `measure { }` in a `fn` body → E0910 (wall-clock stays out of gameplay). + var outside = try parseAndCheck(gpa, + \\fn timed() -> Duration { measure { } } + ); + defer outside.deinit(gpa); + try expectAnyCode(outside.diagnostics.items, .measure_outside_test); + + // Inside a test body it types clean (result is Duration). + var inside = try parseAndCheck(gpa, + \\test "m" { + \\ let d = measure { let x = 1 + 1 } + \\ assert(d > 0.0s) + \\} + ); + defer inside.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), inside.diagnostics.items.len); +} + +test "assert_eq rejects non-comparable aggregate operands (M1.0.15 review fix)" { + const gpa = std.testing.allocator; + // A struct value has no structural equality — comparing it would false-fail + // assert_eq / false-pass assert_neq, so it is rejected at type-check. + var result = try parseAndCheck(gpa, + \\struct P { x: int } + \\test "cmp" { + \\ let a = P { x: 1 } + \\ assert_eq(a, a) + \\} + ); + defer result.deinit(gpa); + try expectAnyCode(result.diagnostics.items, .type_mismatch); + + // Scalars compare clean. + var ok = try parseAndCheck(gpa, + \\test "cmp ok" { assert_eq(1 + 1, 2) } + ); + defer ok.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), ok.diagnostics.items.len); +} + +test "assert_approx constrains its operands to float at type-check (M1.0.15 review fix)" { + const gpa = std.testing.allocator; + // Int operands → type_mismatch at check (symmetric with the rest of the + // family), not a generic runtime failure. + var ints = try parseAndCheck(gpa, + \\test "approx ints" { assert_approx(1, 2) } + ); + defer ints.deinit(gpa); + try expectAnyCode(ints.diagnostics.items, .type_mismatch); + + // Floats (incl. an explicit tolerance) check clean. + var floats = try parseAndCheck(gpa, + \\test "approx floats" { assert_approx(1.0, 1.5, 0.6) } + ); + defer floats.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), floats.diagnostics.items.len); +} + test "type-checker emits E0502 when an annotation is applied to the wrong target (D-S3-annot-applicability)" { const gpa = std.testing.allocator; diff --git a/src/etch/value.zig b/src/etch/value.zig index ba8add99..69bb0ca4 100644 --- a/src/etch/value.zig +++ b/src/etch/value.zig @@ -126,6 +126,12 @@ pub const Value = union(enum) { /// expression (`after(d)` with `d` a Duration local). Duration /// arithmetic stays out of the M1.0.13 surface. duration: f64, + /// The current test's World handle (M1.0.15): returned by `test_world()`, + /// receiver of `spawn_with`/`emit`/`tick`. v0.6 is MONO-WORLD — the payload + /// is a marker (`void`); the interpreter operates on the `world` already + /// threaded through `execStmt`, so repeated `test_world()` calls denote the + /// same world. Not field-storable, not comparable in Etch. + world_handle, unit, pub fn fromInt(x: i64) Value { @@ -182,6 +188,9 @@ pub const Value = union(enum) { .task_handle => false, .timer_handle => false, .duration => |a| a == other.duration, + // Mono-world: the sole world handle is equal to itself; `==` on world + // handles is not an Etch v0.6 operation regardless. + .world_handle => true, .unit => true, }; } @@ -222,6 +231,10 @@ pub const RuntimeErrorKind = enum { /// An Etch `throw` that reached the rule top level uncaught (M0.8 /// E3-D). The span covers the thrown value expression. UncaughtThrow, + /// A failed `assert(...)` / assertion-family builtin (M1.0.15). The span + /// covers the failing condition; the message (compared values, custom + /// reason) travels alongside via the interpreter's `pending_message`. + AssertFailed, }; // ─── Arithmetic helpers ────────────────────────────────────────────────── diff --git a/tests/etch/test_runner/driver_test.zig b/tests/etch/test_runner/driver_test.zig new file mode 100644 index 00000000..1011c497 --- /dev/null +++ b/tests/etch/test_runner/driver_test.zig @@ -0,0 +1,177 @@ +//! M1.0.15 acceptance driver for the Etch test runner. Drives the PUBLIC +//! `weld_etch` surface (parseSource → TypeChecker.check → test_runner.run) from +//! OUTSIDE the etch module, over the `.etch` fixtures in this directory +//! (`@embedFile`d so the tests track the real files the `etch_test` shim reads) +//! plus inline sources for the diagnostic cases. All allocations run under +//! `std.testing.allocator`, so a leak anywhere fails the suite. + +const std = @import("std"); +const weld_etch = @import("weld_etch"); + +const Diagnostic = weld_etch.Diagnostic; +const DiagnosticCode = weld_etch.diagnostics.DiagnosticCode; +const TestStatus = weld_etch.TestStatus; + +// ─── helpers ──────────────────────────────────────────────────────────────── + +/// Parse + type-check (both asserted clean) + run a source's tests; returns the +/// report (caller `deinit`s). A throwaway `std.Io.Threaded` supplies the clock. +fn run(gpa: std.mem.Allocator, source: []const u8) !weld_etch.RunReport { + var pr = try weld_etch.parseSource(gpa, source); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try weld_etch.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + return try weld_etch.test_runner.run(gpa, threaded.io(), &pr.ast); +} + +/// True iff type-checking `source` (parse asserted clean) yields `code`. +fn checkHasCode(gpa: std.mem.Allocator, source: []const u8, code: DiagnosticCode) !bool { + var pr = try weld_etch.parseSource(gpa, source); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try weld_etch.TypeChecker.check(gpa, &pr.ast, &diags); + for (diags.items) |d| if (d.code == code) return true; + return false; +} + +/// Number of type-check diagnostics for `source` (parse asserted clean). +fn checkDiagCount(gpa: std.mem.Allocator, source: []const u8) !usize { + var pr = try weld_etch.parseSource(gpa, source); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try weld_etch.TypeChecker.check(gpa, &pr.ast, &diags); + return diags.items.len; +} + +fn resultByName(report: weld_etch.RunReport, name: []const u8) ?weld_etch.TestResult { + for (report.results) |r| { + if (std.mem.eql(u8, r.name, name)) return r; + } + return null; +} + +// ─── run-outcome fixtures ─────────────────────────────────────────────────── + +test "passing test reports pass" { + const gpa = std.testing.allocator; + var report = try run(gpa, @embedFile("green.etch")); + defer report.deinit(); + // green.etch: 2 passing + 1 @skip. + try std.testing.expectEqual(@as(u32, 2), report.passed); + try std.testing.expectEqual(@as(u32, 0), report.failed); + try std.testing.expectEqual(@as(u32, 1), report.skipped); + const p = resultByName(report, "arithmetic holds").?; + try std.testing.expectEqual(TestStatus.passed, p.status); +} + +test "failing assertion reports name, message, and span" { + const gpa = std.testing.allocator; + var report = try run(gpa, @embedFile("failing.etch")); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 1), report.passed); + try std.testing.expectEqual(@as(u32, 1), report.failed); + const f = resultByName(report, "two plus two is not five").?; + try std.testing.expectEqual(TestStatus.failed, f.status); + // Message carries both compared values (4 and 5). + try std.testing.expect(std.mem.indexOf(u8, f.message.?, "4") != null); + try std.testing.expect(std.mem.indexOf(u8, f.message.?, "5") != null); + // Source span is present and non-empty. + try std.testing.expect(f.span != null); + try std.testing.expect(f.span.?.byte_end > f.span.?.byte_start); +} + +test "skip and only semantics" { + const gpa = std.testing.allocator; + // @skip: reported skipped with its reason, body not run (green.etch's). + var green = try run(gpa, @embedFile("green.etch")); + defer green.deinit(); + const s = resultByName(green, "not ready yet").?; + try std.testing.expectEqual(TestStatus.skipped, s.status); + try std.testing.expectEqualStrings("documented WIP", s.message.?); + + // @only: only the focused test runs; the other is skipped (never fails). + var only = try run(gpa, @embedFile("only.etch")); + defer only.deinit(); + try std.testing.expectEqual(@as(u32, 1), only.passed); + try std.testing.expectEqual(@as(u32, 0), only.failed); + try std.testing.expectEqual(@as(u32, 1), only.skipped); + try std.testing.expectEqual(TestStatus.passed, resultByName(only, "focused").?.status); + try std.testing.expectEqual(TestStatus.skipped, resultByName(only, "would fail if run").?.status); +} + +test "fresh world per test" { + const gpa = std.testing.allocator; + var report = try run(gpa, @embedFile("isolation.etch")); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 2), report.passed); + try std.testing.expectEqual(@as(u32, 0), report.failed); +} + +test "spawn_with returns a live handle and fires observers; tick drives rules and events" { + const gpa = std.testing.allocator; + var report = try run(gpa, @embedFile("world.etch")); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 2), report.passed); + try std.testing.expectEqual(@as(u32, 0), report.failed); +} + +test "measure returns a positive duration; tick_until stops on predicate and timeout" { + const gpa = std.testing.allocator; + var report = try run(gpa, @embedFile("timing.etch")); + defer report.deinit(); + try std.testing.expectEqual(@as(u32, 3), report.passed); + try std.testing.expectEqual(@as(u32, 0), report.failed); +} + +// ─── diagnostic cases (inline: broken programs, not run) ──────────────────── + +test "test name no longer collides with component name" { + const gpa = std.testing.allocator; + const n = try checkDiagCount(gpa, + \\component Foo { x: int = 0 } + \\test "Foo" { assert(true) } + ); + try std.testing.expectEqual(@as(usize, 0), n); +} + +test "duplicate test names are E0101" { + const gpa = std.testing.allocator; + try std.testing.expect(try checkHasCode(gpa, + \\test "dup" { assert(true) } + \\test "dup" { assert(true) } + , .duplicate_symbol)); +} + +test "await in test body is E0901" { + const gpa = std.testing.allocator; + try std.testing.expect(try checkHasCode(gpa, + \\test "no await" { await wait(1.0s) } + , .async_call_in_non_async_context)); +} + +test "measure outside test body is E0910" { + const gpa = std.testing.allocator; + try std.testing.expect(try checkHasCode(gpa, + \\fn timed() -> Duration { measure { } } + , .measure_outside_test)); +} diff --git a/tests/etch/test_runner/failing.etch b/tests/etch/test_runner/failing.etch new file mode 100644 index 00000000..54f17413 --- /dev/null +++ b/tests/etch/test_runner/failing.etch @@ -0,0 +1,11 @@ +// M1.0.15 acceptance corpus — a deliberately failing file: one green test and +// one failing `assert_eq` whose message must carry both compared values and a +// source span. `etch_test` on this file prints a ✗ line and exits nonzero. + +test "this one passes" { + assert_eq(1 + 1, 2) +} + +test "two plus two is not five" { + assert_eq(2 + 2, 5) +} diff --git a/tests/etch/test_runner/green.etch b/tests/etch/test_runner/green.etch new file mode 100644 index 00000000..5b2ca74a --- /dev/null +++ b/tests/etch/test_runner/green.etch @@ -0,0 +1,24 @@ +// M1.0.15 acceptance corpus — an all-green file: passing tests + one @skip. +// Run by `zig build test-etch` (the shim prints ✓ / skipped lines, exits 0). + +component Health { + current: float = 100.0 + max: float = 100.0 +} + +@tag(.unit) +test "arithmetic holds" { + assert_eq(2 + 2, 4) +} + +test "spawn_with yields a live handle" { + let world = test_world() + let e = world.spawn_with([Health { current: 42.0 }]) + assert_eq(e.get(Health).current, 42.0) + assert_eq(e.get(Health).max, 100.0) +} + +@skip(reason: "documented WIP") +test "not ready yet" { + assert(false) +} diff --git a/tests/etch/test_runner/isolation.etch b/tests/etch/test_runner/isolation.etch new file mode 100644 index 00000000..cfb1267f --- /dev/null +++ b/tests/etch/test_runner/isolation.etch @@ -0,0 +1,27 @@ +// M1.0.15 acceptance corpus — a fresh World per test: a rule counts Marker +// entities into a resource each tick; test 2 sees only its own spawn, not +// test 1's two. + +resource Count { n: int = 0 } +component Marker { x: int = 0 } + +rule count(entity: Entity) + when entity has Marker and resource Count +{ + get_mut(Count).n += 1 +} + +test "first spawns two" { + let world = test_world() + world.spawn_with([Marker { x: 1 }]) + world.spawn_with([Marker { x: 2 }]) + world.tick(1) + assert_eq(get(Count).n, 2) +} + +test "second is isolated" { + let world = test_world() + world.spawn_with([Marker { x: 9 }]) + world.tick(1) + assert_eq(get(Count).n, 1) +} diff --git a/tests/etch/test_runner/only.etch b/tests/etch/test_runner/only.etch new file mode 100644 index 00000000..f605801b --- /dev/null +++ b/tests/etch/test_runner/only.etch @@ -0,0 +1,11 @@ +// M1.0.15 acceptance corpus — `@only` focusing: with at least one `@only` test +// present, only those run; the rest are reported skipped (never failing). + +@only +test "focused" { + assert(true) +} + +test "would fail if run" { + assert(false) +} diff --git a/tests/etch/test_runner/timing.etch b/tests/etch/test_runner/timing.etch new file mode 100644 index 00000000..4669f4cc --- /dev/null +++ b/tests/etch/test_runner/timing.etch @@ -0,0 +1,28 @@ +// M1.0.15 acceptance corpus — `measure` and `tick_until`. + +resource Counter { n: int = 0 } +rule inc() + when resource Counter +{ + get_mut(Counter).n += 1 +} + +test "measure yields a positive duration" { + let elapsed = measure { + let mut i: int = 0 + while i < 5000 { i += 1 } + } + assert(elapsed > 0.0s) +} + +test "tick_until stops on the predicate" { + let world = test_world() + let hit = tick_until(|| get(Counter).n >= 3, 1.0s) + assert(hit) +} + +test "tick_until stops on timeout" { + let world = test_world() + let hit = tick_until(|| get(Counter).n >= 1000, 0.05s) + assert(not hit) +} diff --git a/tests/etch/test_runner/world.etch b/tests/etch/test_runner/world.etch new file mode 100644 index 00000000..8eedd761 --- /dev/null +++ b/tests/etch/test_runner/world.etch @@ -0,0 +1,42 @@ +// M1.0.15 acceptance corpus — the test-world surface: spawn_with fires +// observers (on_added -> emit -> @on_event tallies), and world.emit + tick +// drives an event-handling rule. + +component Marker { x: int = 0 } +event Spawned { by: int } +event Damage { amount: int } +resource Count { n: int = 0 } +resource Tally { total: int = 0 } + +@on_added(Marker) +rule note(entity: Entity, value: Marker) { + emit Spawned { by: value.x } +} + +@on_event(Spawned) +rule tally_spawn() + when resource Count +{ + get_mut(Count).n += 1 +} + +@on_event(Damage) +rule absorb() + when resource Tally +{ + get_mut(Tally).total += event.amount +} + +test "spawn_with fires observers" { + let world = test_world() + world.spawn_with([Marker { x: 7 }]) + world.tick(1) + assert_eq(get(Count).n, 1) +} + +test "emit + tick drives a rule" { + let world = test_world() + world.emit(Damage { amount: 30 }) + world.tick(1) + assert_eq(get(Tally).total, 30) +} diff --git a/tools/etch_test/main.zig b/tools/etch_test/main.zig new file mode 100644 index 00000000..f5267c36 --- /dev/null +++ b/tools/etch_test/main.zig @@ -0,0 +1,151 @@ +//! `etch_test` — thin CLI shim over the M1.0.15 Etch test runner +//! (`weld_etch.test_runner`). The shim owns arg parsing + file I/O + report +//! printing only; parse + type-check + run all live in the library, which +//! `weld test` (`engine-spec.md §26.1`) will consume through the same entry. +//! +//! CLI: +//! etch_test [ ...] +//! +//! Each input file is its OWN compilation set (imports resolved as today). +//! For each file: parse → type-check → run its `test` blocks. Prints a per-test +//! `✓ ()` / `✗ (:)` / `- +//! (skipped: )` line, then the `N passed / M failed / K skipped` +//! aggregate. Exits 0 iff every file has zero diagnostics AND zero test +//! failures; otherwise a nonzero exit. + +const std = @import("std"); +const weld_etch = @import("weld_etch"); + +const Diagnostic = weld_etch.Diagnostic; + +pub fn main(init: std.process.Init) !void { + const gpa = init.gpa; + const arena = init.arena; + const io = init.io; + const argv = try init.minimal.args.toSlice(arena.allocator()); + const cwd = std.Io.Dir.cwd(); + + var out_buf: [8 * 1024]u8 = undefined; + var out_w = std.Io.File.stdout().writer(io, &out_buf); + const out = &out_w.interface; + + if (argv.len < 2) { + try out.writeAll("usage: etch_test [ ...]\n"); + try out.flush(); + return error.InvalidArgs; + } + + var any_failure = false; + var total_pass: u32 = 0; + var total_fail: u32 = 0; + var total_skip: u32 = 0; + + var i: usize = 1; + while (i < argv.len) : (i += 1) { + const path = argv[i]; + const source = readWholeFile(arena.allocator(), io, cwd, path) catch |err| { + try out.print("etch_test: cannot read {s}: {s}\n", .{ path, @errorName(err) }); + any_failure = true; + continue; + }; + + // Parse. + var pr = weld_etch.parseSource(gpa, source) catch |err| { + try out.print("etch_test: parse failed for {s}: {s}\n", .{ path, @errorName(err) }); + any_failure = true; + continue; + }; + defer pr.deinit(gpa); + if (pr.diagnostics.len > 0) { + try out.print("{s}: {d} parse diagnostic(s)\n", .{ path, pr.diagnostics.len }); + for (pr.diagnostics) |d| try printDiag(out, path, source, d); + any_failure = true; + continue; + } + + // Type-check. + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try weld_etch.TypeChecker.check(gpa, &pr.ast, &diags); + if (diags.items.len > 0) { + try out.print("{s}: {d} diagnostic(s)\n", .{ path, diags.items.len }); + for (diags.items) |d| try printDiag(out, path, source, d); + any_failure = true; + continue; + } + + // Run the file's tests. + var report = weld_etch.test_runner.run(gpa, io, &pr.ast) catch |err| { + try out.print("etch_test: run failed for {s}: {s}\n", .{ path, @errorName(err) }); + any_failure = true; + continue; + }; + defer report.deinit(); + + try out.print("{s}:\n", .{path}); + for (report.results) |r| { + switch (r.status) { + .passed => try out.print(" \u{2713} {s} ({d:.3} ms)\n", .{ r.name, msOf(r.duration_ns) }), + .failed => { + const line = if (r.span) |s| lineOf(source, s.byte_start) else 0; + try out.print(" \u{2717} {s} \u{2014} {s} ({s}:{d})\n", .{ r.name, r.message orelse "test failed", path, line }); + }, + .skipped => try out.print(" - {s} (skipped: {s})\n", .{ r.name, r.message orelse "" }), + } + } + try out.print(" {d} passed / {d} failed / {d} skipped\n", .{ report.passed, report.failed, report.skipped }); + + total_pass += report.passed; + total_fail += report.failed; + total_skip += report.skipped; + if (report.failed > 0) any_failure = true; + } + + if (argv.len > 2) { + try out.print("total: {d} passed / {d} failed / {d} skipped\n", .{ total_pass, total_fail, total_skip }); + } + try out.flush(); + // Clean nonzero exit on any diagnostic / failure — no Zig error trace (the + // shader_compiler `--check` precedent); the ✗ lines above are the report. + if (any_failure) std.process.exit(1); +} + +/// Nanoseconds → milliseconds (f64), for the per-test duration display. +fn msOf(ns: u64) f64 { + return @as(f64, @floatFromInt(ns)) / @as(f64, std.time.ns_per_ms); +} + +/// 1-based line number of a byte offset in `source` (newline count + 1). +fn lineOf(source: []const u8, byte: u32) usize { + const end = @min(byte, source.len); + var line: usize = 1; + for (source[0..end]) |c| { + if (c == '\n') line += 1; + } + return line; +} + +fn printDiag(out: *std.Io.Writer, path: []const u8, source: []const u8, d: Diagnostic) !void { + const line = lineOf(source, d.primary_span.byte_start); + try out.print(" [{s}] {s} ({s}:{d})\n", .{ d.code.code(), d.primary_message, path, line }); +} + +fn readWholeFile(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, path: []const u8) ![]u8 { + var file = try dir.openFile(io, path, .{}); + defer file.close(io); + const stat = try file.stat(io); + const buf = try gpa.alloc(u8, stat.size); + errdefer gpa.free(buf); + var read_buf: [16 * 1024]u8 = undefined; + var reader = file.reader(io, &read_buf); + var written: usize = 0; + while (written < buf.len) { + const n = try reader.interface.readSliceShort(buf[written..]); + if (n == 0) break; + written += n; + } + return buf[0..written]; +}